From e78270631e6faffbc25e633d28bc5c6133cfcfd9 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 18:10:12 +1000 Subject: [PATCH 01/88] fix(unit-hub): line up the header controls and use the app's own unit picker --- src/app/unit-hub/unit-hub.component.html | 45 ++++++++++----------- src/app/unit-hub/unit-hub.component.scss | 33 ++++++++++++--- src/app/unit-hub/unit-hub.component.spec.ts | 4 +- src/app/unit-hub/unit-hub.component.ts | 4 ++ 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index 77727f088a..7027d2df38 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -10,45 +10,43 @@

Unit Hub

@if (showFeed) { - + + }
@if (manageableUnits.length) { }
@@ -733,8 +731,8 @@

rel="noopener noreferrer" target="_blank" [href]="googleUrl(session, unitCode(session.unit_id))" - >Preview DEMO in - Google CalendarPreview DEMO in Google + Calendar + } } @if (tab !== 'announcements') { @@ -644,7 +659,7 @@

Upcoming

Scheduled sessions through {{ feed.window_end | date: 'd MMM yyyy' }}. }

- @for (group of sessionGroups; track group.key) { + @for (group of visibleSessionGroups; track group.key) {

@if (group.label) { @@ -809,6 +824,20 @@

No sessions scheduled yet

}
} + @if (sessionGroups.length > sessionDayPage) { + + }
From dfcc10f0673619732e1dbec10eb674f3a9e5d926 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:31:32 +1000 Subject: [PATCH 09/88] fix(unit-hub): label the details sections with icons and drop the divider --- .../unit-hub/unit-hub-details.component.html | 30 ++++++++++--------- .../unit-hub/unit-hub-details.component.scss | 22 ++++++++++++-- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/app/unit-hub/unit-hub-details.component.html b/src/app/unit-hub/unit-hub-details.component.html index 11b4f8cd53..82e849432e 100644 --- a/src/app/unit-hub/unit-hub-details.component.html +++ b/src/app/unit-hub/unit-hub-details.component.html @@ -26,16 +26,16 @@

{{ title }}

@if (detail.announcement; as announcement) {
-
Unit
+
Unit
{{ detail.unitCode }} · {{ detail.unitName }}
-
Source
+
Source
{{ sourceLabel }}
@if (announcement.published_at) {
-
Published
+
Published
Posted by
+
Posted by
{{ announcement.author_name }}
} @if (announcement.expires_at) {
-
Visible until
+
Visible until
{{ announcement.expires_at | date: 'd MMMM yyyy, h:mm a' }}{{ deviceTimeZone }} @@ -61,7 +61,7 @@

{{ title }}

} @if (announcement.pinned) {
-
Priority
+
Priority
Pinned announcement
} @@ -88,15 +88,15 @@

{{ title }}

}
-
Session
+
Session
{{ sessionKind }}
-
Unit
+
Unit
{{ detail.unitCode }} · {{ detail.unitName }}
-
Starts
+
Starts
-
Ends
+
Ends
@if (session.timezone !== deviceTimeZone) {
-
Schedule time zone
+
Schedule time zone
{{ session.timezone }}Dates above are converted to your time zone.
@@ -123,12 +123,12 @@

{{ title }}

} @if (session.location) {
-
Location
+
Location
{{ session.location }}
}
-
Repeat
+
Repeat
{{ session.recurrence === 'weekly' ? 'Part of a weekly series' : 'One session' }} @if (session.recurrence === 'weekly' && session.recurrence_until) { @@ -151,7 +151,9 @@

{{ title }}

} @if (!session.cancelled) {
+

{{ announcement.body }}

@if (safeUrl(announcement.source_url)) { Open original post Open original post } } @if (detail.session; as session) { @if (session.cancelled) { -

- Cancelled · this session will not run. -

+
+ +

Cancelled · this session will not run.

+
}
@@ -138,15 +141,17 @@

{{ title }}

+

{{ session.description || 'Your teaching team has not added any further details.' }}

@if (safeUrl(session.source_url)) { View original session details View original session details } @if (!session.cancelled) { @@ -207,6 +212,8 @@

} } - + } diff --git a/src/app/unit-hub/unit-hub-details.component.scss b/src/app/unit-hub/unit-hub-details.component.scss index 633c897605..bb1303621d 100644 --- a/src/app/unit-hub/unit-hub-details.component.scss +++ b/src/app/unit-hub/unit-hub-details.component.scss @@ -1,3 +1,7 @@ +@use './hub-card' as hub; + +@include hub.card-theme; + :host { display: flex; flex-direction: column; @@ -40,7 +44,11 @@ h2[mat-dialog-title] { h2[mat-dialog-title]::before { display: none; } +// grouping comes from cards and one vertical rhythm, not rules between sections .details-content { + display: flex; + flex-direction: column; + gap: 16px; min-height: 0; flex: 1; max-height: none; @@ -48,36 +56,24 @@ h2[mat-dialog-title]::before { color: inherit; overflow-wrap: anywhere; } +.details-content > * { + margin: 0; +} +// the dialog is a raised surface, so every card here takes the surface token .detail-facts { + @include hub.card(raised); display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px 24px; - margin: 16px 0 24px; + margin-top: 10px; padding: 16px; - border: 1px solid var(--ot-color-divider); - border-radius: var(--ot-radius-md); - background: var(--ot-color-surface-raised); } .detail-facts > div { min-width: 0; } dt { - display: flex; - align-items: center; - gap: 6px; - color: var(--ot-color-text-muted); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; + @include hub.label; margin-bottom: 4px; - - mat-icon { - width: 16px; - height: 16px; - font-size: 16px; - color: var(--ot-color-link); - } } dd { margin: 0; @@ -90,83 +86,62 @@ small { font-size: 0.76rem; margin-top: 3px; } +// the body gets its own quiet card on the same inset as the facts card .full-content { + @include hub.card(raised); + padding: 16px; line-height: 1.7; } +.content-label { + @include hub.label; + margin-bottom: 8px; +} .full-content p { white-space: pre-wrap; margin: 0; + font-size: 0.95rem; } .detail-notice, +.cancelled-notice { + @include hub.callout(raised); + p { + margin: 0; + } +} +.cancelled-notice { + border-color: color-mix(in srgb, var(--ot-color-error) 60%, var(--hub-card-border)); + > mat-icon { + color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text)); + } +} .calendar-guidance { - background: var(--ot-color-surface-raised); + @include hub.card(raised); padding: 16px; - border: 1px solid var(--ot-color-divider); - border-radius: var(--ot-radius-md); - margin: 6px 0 18px; font-size: 0.84rem; line-height: 1.6; } -.detail-notice { - display: flex; - align-items: flex-start; - gap: 12px; -} -.detail-notice mat-icon { - flex-shrink: 0; -} -.detail-notice p { - margin: 0; -} -.calendar-guidance { - margin: 24px 0 0; -} .calendar-guidance h3 { - font-size: 1rem; - font-weight: 650; - margin: 0 0 8px; + @include hub.icon-heading; + margin-bottom: 8px; } .calendar-guidance p { - margin: 0 0 8px; -} -.cancelled-notice { - color: var(--ot-color-text); - border: 1px solid var(--ot-color-error); - border-left-width: 4px; - padding: 12px 16px; - border-radius: var(--ot-radius-sm); + margin: 0 0 12px; } .detail-source { - display: inline-flex; - align-items: center; - gap: 6px; - margin-top: 22px; - color: var(--ot-color-link); - text-underline-offset: 3px; -} -.detail-source mat-icon { - font-size: 18px; - width: 18px; - height: 18px; + align-self: flex-start; + text-decoration: none; } .details-actions { - display: flex; - flex-wrap: wrap; + @include hub.action-row; flex-shrink: 0; justify-content: flex-end; - gap: 8px; - border-top: 1px solid var(--ot-color-divider); padding: 14px 24px; } -.details-actions a { - text-decoration: none; -} .detail-status:empty { display: none; } .detail-status { font-size: 0.84rem; - margin-top: 14px; } a:focus-visible, button:focus-visible { @@ -184,28 +159,13 @@ button:focus-visible { padding: 12px 18px; } .detail-facts { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); gap: 14px; } h2[mat-dialog-title] { font-size: 1.22rem; } - .details-actions a, - .details-actions button { - min-height: 44px; - } -} - -.full-content p { - font-size: 0.95rem; -} - -.calendar-guidance h3 { - display: flex; - align-items: center; - gap: 8px; - - mat-icon { - color: var(--ot-color-link); + .detail-source { + align-self: stretch; } } From 36913a57bcbb3425f100cf9fc84b0429b05c7cd5 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:47:39 +1000 Subject: [PATCH 12/88] fix(unit-hub): drop the primary border from pinned announcement cards Pinned is shown by its chip. Every announcement card now uses the neutral card edge at rest. --- src/app/unit-hub/unit-hub.component.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index a57c3e727e..ff9cc51d8b 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -326,9 +326,6 @@ mat-icon { box-shadow: var(--ot-elevation-1); overflow-wrap: anywhere; } -.announcement-card.is-pinned { - border-color: var(--ot-color-primary); -} .card-meta { display: flex; flex-wrap: wrap; From d459b7baee53ed5e74fa7122897279306581e86e Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:47:43 +1000 Subject: [PATCH 13/88] style(buttons): one filled and one outlined style across the app --- src/styles.scss | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/styles.scss b/src/styles.scss index d67746fca5..cca9d65450 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -193,6 +193,35 @@ body.split-pane-resizing pdf-viewer { background-color: color-mix(in srgb, var(--ot-color-primary) 11%, transparent); } +// Filled buttons. Without a color input Material fills them with a neutral grey, +// which in dark mode reads as a disabled box rather than the main action. Give +// every filled button the primary fill and the same shape as outlined ones, so +// the app has two clear button styles: filled for the main action, outlined for +// the rest. Buttons that set color="warn" keep their own fill. +.mat-mdc-unelevated-button:not(:disabled):not(.mat-warn) { + --mat-button-filled-container-color: var(--ot-color-primary); + --mat-button-filled-label-text-color: var(--ot-color-on-primary); + --mat-button-filled-container-shape: var(--ot-radius-md); + box-shadow: none; + + .mat-icon { + color: inherit; + } +} + +.mat-mdc-unelevated-button:disabled { + --mat-button-filled-container-shape: var(--ot-radius-md); +} + +.mat-mdc-unelevated-button:not(:disabled), +.mat-mdc-outlined-button:not(:disabled) { + transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1); + + &:active { + transform: scale(0.97); + } +} + // ngx-skeleton-loader sets a fixed light grey and a white shimmer on its own // element, in its encapsulated styles, so every loading placeholder stayed light // in dark mode. Restate them there with the divider token the hand-built From ed8965b05b925b9ce9be193f772e02f4adbb6249 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:47:58 +1000 Subject: [PATCH 14/88] feat(unit-hub): show which announcements are new and let students mark them read Unread cards get a left accent bar, a bold title and a New chip, and the heading shows the new count with a Mark all as read button. Opening the details or pressing the card's mark as read button records a read time. An announcement edited after that time counts as new again. There is no read-receipt API, so read times are kept per user in localStorage and are per browser. Storage failures count as all unread, ids that leave the feed are pruned, and demo content is never stored. --- .../unit-hub/announcement-read.store.spec.ts | 145 ++++++++++++++++++ src/app/unit-hub/announcement-read.store.ts | 132 ++++++++++++++++ src/app/unit-hub/unit-hub.component.html | 42 ++++- src/app/unit-hub/unit-hub.component.scss | 49 ++++++ src/app/unit-hub/unit-hub.component.spec.ts | 76 +++++++++ src/app/unit-hub/unit-hub.component.ts | 19 +++ src/app/unit-hub/unit-hub.models.ts | 2 + 7 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 src/app/unit-hub/announcement-read.store.spec.ts create mode 100644 src/app/unit-hub/announcement-read.store.ts diff --git a/src/app/unit-hub/announcement-read.store.spec.ts b/src/app/unit-hub/announcement-read.store.spec.ts new file mode 100644 index 0000000000..f608d8dba7 --- /dev/null +++ b/src/app/unit-hub/announcement-read.store.spec.ts @@ -0,0 +1,145 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {UserService} from 'src/app/api/services/user.service'; +import {AnnouncementReadStore, READ_STORE_LIMIT} from './announcement-read.store'; + +function memoryStorage(): Storage { + const values: Map = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => Array.from(values.keys())[index] ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, String(value)), + }; +} + +const KEY = 'ontrack.unitHub.read.7'; + +const row = (id: number, published_at = '2026-09-10T00:00:00.000Z', updated_at?: string) => ({ + id, + published_at, + updated_at, +}); + +describe('AnnouncementReadStore', () => { + let user: {currentUser: {id?: number}}; + + beforeEach(() => { + // the test runtime has no usable localStorage of its own + Object.defineProperty(globalThis, 'localStorage', {configurable: true, value: memoryStorage()}); + user = {currentUser: {id: 7}}; + TestBed.configureTestingModule({providers: [{provide: UserService, useValue: user}]}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + const fresh = () => TestBed.inject(AnnouncementReadStore); + + it('round trips read times through a per-user key', () => { + const store = fresh(); + store.sync([row(1), row(2)]); + expect(store.isUnread(row(1))).toBe(true); + store.markRead([row(1)], new Date('2026-09-12T00:00:00.000Z')); + expect(store.isUnread(row(1))).toBe(false); + expect(store.isUnread(row(2))).toBe(true); + expect(JSON.parse(localStorage.getItem(KEY))).toEqual({'1': '2026-09-12T00:00:00.000Z'}); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({providers: [{provide: UserService, useValue: user}]}); + const reloaded = fresh(); + reloaded.sync([row(1), row(2)]); + expect(reloaded.isUnread(row(1))).toBe(false); + }); + + it('counts an announcement edited after it was read as unread again', () => { + const store = fresh(); + store.sync([row(1)]); + store.markRead([row(1)], new Date('2026-09-12T00:00:00.000Z')); + expect(store.isUnread(row(1, '2026-09-10T00:00:00.000Z', '2026-09-11T00:00:00.000Z'))).toBe( + false, + ); + expect(store.isUnread(row(1, '2026-09-10T00:00:00.000Z', '2026-09-13T00:00:00.000Z'))).toBe( + true, + ); + // republished later with no edit time still counts + expect(store.isUnread(row(1, '2026-09-14T00:00:00.000Z'))).toBe(true); + }); + + it('treats storage that throws as all unread and never crashes', () => { + localStorage.setItem(KEY, JSON.stringify({'1': '2026-09-12T00:00:00.000Z'})); + vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + const store = fresh(); + expect(() => store.sync([row(1)])).not.toThrow(); + expect(store.isUnread(row(1))).toBe(true); + expect(() => store.markRead([row(1)])).not.toThrow(); + // the mark still applies in this tab + expect(store.isUnread(row(1))).toBe(false); + }); + + it('survives a runtime with no localStorage at all', () => { + Object.defineProperty(globalThis, 'localStorage', {configurable: true, value: undefined}); + const store = fresh(); + expect(() => store.sync([row(1)])).not.toThrow(); + expect(store.isUnread(row(1))).toBe(true); + expect(() => store.markRead([row(1)])).not.toThrow(); + }); + + it('ignores corrupt stored values', () => { + localStorage.setItem(KEY, '{not json'); + const store = fresh(); + store.sync([row(1)]); + expect(store.isUnread(row(1))).toBe(true); + localStorage.setItem(KEY, JSON.stringify({'1': 'yesterday', '2': 5})); + store.sync([row(1), row(2)]); + expect(store.isUnread(row(1))).toBe(true); + expect(store.isUnread(row(2))).toBe(true); + }); + + it('prunes ids that are no longer in the feed and caps the stored entries', () => { + const many: Record = {}; + for (let id = 1; id <= READ_STORE_LIMIT + 20; id++) { + many[id] = new Date(Date.UTC(2026, 8, 1) + id * 60000).toISOString(); + } + many['9999'] = '2026-09-12T00:00:00.000Z'; + localStorage.setItem(KEY, JSON.stringify(many)); + const store = fresh(); + const feed = Object.keys(many) + .filter((id) => id !== '9999') + .map((id) => row(Number(id), '2026-08-01T00:00:00.000Z')); + store.sync(feed); + const saved = JSON.parse(localStorage.getItem(KEY)); + expect(saved['9999']).toBeUndefined(); + expect(Object.keys(saved)).toHaveLength(READ_STORE_LIMIT); + // the oldest reads are the ones dropped + expect(saved['1']).toBeUndefined(); + expect(saved[String(READ_STORE_LIMIT + 20)]).toBeDefined(); + }); + + it('keeps demo content out of storage and does not prune real entries', () => { + localStorage.setItem(KEY, JSON.stringify({'42': '2026-09-12T00:00:00.000Z'})); + const store = fresh(); + store.sync([row(1)], false); + store.markRead([row(1)]); + expect(store.isUnread(row(1))).toBe(false); + expect(JSON.parse(localStorage.getItem(KEY))).toEqual({'42': '2026-09-12T00:00:00.000Z'}); + }); + + it('does not persist without a signed-in user', () => { + user.currentUser = {}; + const store = fresh(); + store.sync([row(1)]); + store.markRead([row(1)]); + expect(localStorage.length).toBe(0); + expect(store.isUnread(row(1))).toBe(false); + }); +}); diff --git a/src/app/unit-hub/announcement-read.store.ts b/src/app/unit-hub/announcement-read.store.ts new file mode 100644 index 0000000000..af77f90ce7 --- /dev/null +++ b/src/app/unit-hub/announcement-read.store.ts @@ -0,0 +1,132 @@ +import {Injectable, inject} from '@angular/core'; +import {UserService} from 'src/app/api/services/user.service'; +import {UnitAnnouncement} from './unit-hub.models'; + +/** Most read times kept per user. The feed itself is capped well below this. */ +export const READ_STORE_LIMIT = 500; + +type ReadTimes = Record; +type ReadableAnnouncement = Pick; + +/** + * Remembers which Unit Hub announcements the signed-in user has opened. + * + * There is no read-receipt API yet, so this lives in localStorage and is per browser. + * Storage can be missing, full or blocked. Every access is guarded, and a failed read + * means everything counts as unread. Marks made in this tab still apply until reload. + */ +@Injectable({providedIn: 'root'}) +export class AnnouncementReadStore { + private readonly users = inject(UserService, {optional: true}); + private reads: ReadTimes = {}; + private readsKey: string | null = null; + private persist = true; + + /** Storage key for the current user, or null when nobody is signed in. */ + get key(): string | null { + let id: unknown; + try { + id = this.users?.currentUser?.id; + } catch { + return null; + } + return id === undefined || id === null || id === '' ? null : `ontrack.unitHub.read.${id}`; + } + + /** + * Loads the stored read times for the current user, keeps only ids still in the feed + * and writes the pruned set back. Pass persist false for content that is not real, + * such as the demo feed, so it never touches or prunes the stored entries. + */ + sync(announcements: readonly ReadableAnnouncement[], persist = true): void { + const key = this.key; + if (!persist || key !== this.readsKey || !this.persist) { + // a different user or a switch between demo and real content starts clean + this.reads = {}; + } + this.persist = persist; + this.readsKey = key; + if (!persist) { + return; + } + const ids = new Set(announcements.map((row) => String(row.id))); + const kept: ReadTimes = {}; + // marks made in this tab survive a storage write that failed, the later time wins + for (const [id, readAt] of [...Object.entries(this.load()), ...Object.entries(this.reads)]) { + if (ids.has(id) && !(Date.parse(kept[id] ?? '') >= Date.parse(readAt))) { + kept[id] = readAt; + } + } + this.reads = this.capped(kept); + this.save(); + } + + isUnread(row: ReadableAnnouncement): boolean { + const readAt = Date.parse(this.reads[String(row.id)] ?? ''); + if (!Number.isFinite(readAt)) { + return true; + } + const changedAt = Date.parse(row.updated_at || row.published_at || ''); + return Number.isFinite(changedAt) && changedAt > readAt; + } + + markRead(rows: readonly ReadableAnnouncement[], now = new Date()): void { + if (!rows.length) { + return; + } + for (const row of rows) { + // A server clock ahead of this device must not leave a just-opened post unread. + const changedAt = Date.parse(row.updated_at || row.published_at || ''); + const readAt = Number.isFinite(changedAt) + ? Math.max(now.getTime(), changedAt) + : now.getTime(); + this.reads[String(row.id)] = new Date(readAt).toISOString(); + } + this.reads = this.capped(this.reads); + this.save(); + } + + private load(): ReadTimes { + const key = this.key; + if (!key) { + return {}; + } + try { + const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + const result: ReadTimes = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (typeof value === 'string' && Number.isFinite(Date.parse(value))) { + result[id] = value; + } + } + return result; + } catch { + return {}; + } + } + + private save(): void { + const key = this.key; + if (!key || !this.persist) { + return; + } + try { + localStorage.setItem(key, JSON.stringify(this.reads)); + } catch { + // Storage is full or blocked. The marks stay in memory for this page. + } + } + + /** Keeps the most recently read entries when there are more than the limit. */ + private capped(reads: ReadTimes): ReadTimes { + const entries = Object.entries(reads); + if (entries.length <= READ_STORE_LIMIT) { + return reads; + } + entries.sort((a, b) => Date.parse(b[1]) - Date.parse(a[1])); + return Object.fromEntries(entries.slice(0, READ_STORE_LIMIT)); + } +} diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index ca799f2b16..ec8b8060e5 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -624,7 +624,22 @@

No active units to show

Announcements

- {{ announcements.length }} + {{ announcements.length }} + @if (unreadCount) { + · {{ unreadCount }} new + } + + @if (unreadCount) { + + }
@if (feed.announcements_truncated) {

@@ -633,8 +648,19 @@

Announcements

} @for (announcement of visibleAnnouncements; track announcement.id) { -
+ @let unread = isUnread(announcement); +
+ @if (unread) { + New + } {{ unitCode(announcement.unit_id) }} @if (announcement.pinned) { Announcements

announcement.published_at | date: 'd MMM' }} + @if (unread) { + + }

+ {{ unread ? 'Unread: ' : 'Read: ' }}

New announcement + } @else if (managing) {
@@ -752,7 +754,10 @@

You're up to date

type="button" (click)="showAllAnnouncements = !showAllAnnouncements" > - {{ + {{ showAllAnnouncements ? 'Show fewer' : 'Show all ' + announcements.length + ' announcements' @@ -934,7 +939,10 @@

No sessions scheduled yet

type="button" (click)="showAllSessionDays = !showAllSessionDays" > - {{ + {{ showAllSessionDays ? 'Show fewer days' : 'Show all ' + sessionGroups.length + ' days' diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index a2241a7301..97229c9f11 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -13,6 +13,9 @@ --hub-space-5: 24px; --hub-space-6: 32px; --hub-space-7: 48px; + // every Unit Hub button: the app's filled or outlined style, at a 44px touch height + --mat-button-filled-container-height: 44px; + --mat-button-outlined-container-height: 44px; } .unit-hub { max-width: 1240px; @@ -144,23 +147,11 @@ mat-icon { align-items: center; gap: var(--hub-space-2); } -// one height, radius and icon size for every header action, so outlined and -// filled buttons read as one set -.hub-action.mat-mdc-button-base { - height: 44px; - padding-inline: 16px; - border-radius: var(--ot-radius-pill); - font-weight: 600; - mat-icon { - width: 20px; - height: 20px; - margin-right: 6px; - font-size: 20px; - } -} -.hub-action--primary.mat-mdc-button-base:not(:disabled) { - background: var(--ot-color-primary); - color: var(--ot-color-on-primary, #fff); +// one icon size for every header action; shape and colour come from the app button styles +.hub-action.mat-mdc-button-base mat-icon { + width: 20px; + height: 20px; + font-size: 20px; } .narrow-only { display: none; @@ -632,9 +623,7 @@ mat-icon { } .show-more.mat-mdc-button-base { width: 100%; - height: 44px; margin-top: var(--hub-space-2); - border-radius: var(--ot-radius-pill); } // ---- Manage panel ---- @@ -838,7 +827,6 @@ fieldset { // ---- Read status (student feed only) ---- .section-heading .mark-all-read.mat-mdc-button-base { margin-left: auto; - border-radius: var(--ot-radius-pill); } .announcement-card.is-unread { // a 4px primary accent bar on the left, inside the rounded edge diff --git a/src/app/unit-hub/unit-hub.component.spec.ts b/src/app/unit-hub/unit-hub.component.spec.ts index 5f40b47c54..f5f58fdfaa 100644 --- a/src/app/unit-hub/unit-hub.component.spec.ts +++ b/src/app/unit-hub/unit-hub.component.spec.ts @@ -129,8 +129,8 @@ describe('Unit Hub route, forms and rendered content', () => { const {harness, component, element} = await open(); expect(element.textContent).toContain('Updates are unavailable'); expect(component.sessions).toEqual([]); - const retry = Array.from(element.querySelectorAll('button')).find( - (button) => button.textContent === 'Try again', + const retry = Array.from(element.querySelectorAll('button')).find((button) => + button.textContent.trim().endsWith('Try again'), ); retry.click(); harness.detectChanges(); From ae99f13a7010464bdf367e6ef023853529179c8b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:50:28 +1000 Subject: [PATCH 16/88] fix(task-status-card): status-coloured button icons and aligned action spacing --- .../task-status-card/task-status-card.component.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss index aa29e7e791..cb5a81a99e 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss @@ -74,6 +74,11 @@ min-height: 44px; background-color: color-mix(in srgb, var(--tsc) 12%, var(--ot-color-surface-raised)); box-shadow: none; + + // the icon takes the same status hue as the outline, not the global primary + .mat-icon { + color: var(--tsc); + } } ::ng-deep f-task-status-card .mat-mdc-card-actions .mat-mdc-outlined-button:not(:disabled):hover { @@ -86,7 +91,8 @@ align-items: flex-start; justify-content: space-between; gap: 0.5rem; - padding: 0 0.75rem 0.75rem; + // line the buttons up with the card text above and give them room to breathe + padding: 0.75rem 16px 16px; } .task-status-primary-actions { From ef58e02018f784529df9ae64edced9d7b996b356 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:50:50 +1000 Subject: [PATCH 17/88] fix(task-submission-card): outline every submission action --- .../task-submission-card/task-submission-card.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html index a33e29097d..28a92865e8 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html @@ -80,13 +80,13 @@

Your submissio } @if (canRegeneratePdf) { - } @if (task?.inSubmittedState()) { - From ac5f34d7c531de6c45a017089bb2757d65c9d619 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:52:08 +1000 Subject: [PATCH 18/88] fix(comments): space status changes in the thread and show them as pills --- .../task-comments-viewer.component.scss | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss index c3aa0c0462..a4fe85d193 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss @@ -172,6 +172,8 @@ $comment-inner-border-radius: 4px; .comment-container .comment-status { width: 100%; align-self: flex-end; + // room above and below so a status change reads as its own line in the thread + margin-block: 12px; hr { margin: 0; @@ -184,8 +186,8 @@ $comment-inner-border-radius: 4px; border: 0; color: var(--ot-color-text-muted); text-align: center; - height: 1.5em; - opacity: 0.8; + height: auto; + margin: 0; &:before { content: ''; @@ -197,15 +199,19 @@ $comment-inner-border-radius: 4px; height: 1px; } + // a pill on the line, not a dark box cut out of it &:after { content: attr(data-content); position: relative; display: inline-block; + padding: 2px 12px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-pill); + background-color: var(--ot-color-surface-raised); color: var(--ot-color-text-muted); - padding: 0 0.5em; + font-size: 0.8125rem; + font-weight: 600; line-height: 1.5em; - color: var(--ot-color-text-muted); - background-color: var(--ot-color-page); } } } From 33fcab57286a25261c8e49e4b6d1c6f41890dc56 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:52:30 +1000 Subject: [PATCH 19/88] feat(celebrate): add motion tokens, animated check, particles and seen-status store Shared pieces for task milestone moments. The check draws a ring then a tick with stroke-dashoffset, the particles are a single burst of transformed dots, and both fall back to a static fade under reduced motion. The seen-status service keeps the last status a student saw per task under ontrack.taskStatusSeen.., and treats a first visit or blocked storage as nothing to celebrate. --- src/app/common/celebrate/_motion.scss | 60 +++++++++ .../celebrate/animated-check.component.html | 35 +++++ .../celebrate/animated-check.component.scss | 103 ++++++++++++++ .../animated-check.component.spec.ts | 64 +++++++++ .../celebrate/animated-check.component.ts | 38 ++++++ .../celebration-particles.component.html | 13 ++ .../celebration-particles.component.scss | 44 ++++++ .../celebration-particles.component.ts | 57 ++++++++ src/app/common/celebrate/reduced-motion.ts | 12 ++ .../task-status-seen.service.spec.ts | 107 +++++++++++++++ .../celebrate/task-status-seen.service.ts | 126 ++++++++++++++++++ 11 files changed, 659 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/reduced-motion.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..9d7e204c72 --- /dev/null +++ b/src/app/common/celebrate/animated-check.component.scss @@ -0,0 +1,103 @@ +@use './motion' as motion; + +:host { + @include motion.tokens; + + --check-color: var(--ot-color-success); + 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 240ms motion.$ease-out both; + animation-delay: calc(var(--check-delay, 0ms) + 520ms); +} + +.ring-track { + stroke: currentColor; + opacity: 0.16; +} + +.ring, +.tick { + stroke: currentColor; + stroke-dasharray: 1; + stroke-dashoffset: 1; +} + +.ring { + stroke-linecap: round; + animation: check-draw motion.$duration-draw motion.$ease-out forwards; + animation-delay: var(--check-delay, 0ms); +} + +.tick { + animation: check-draw 240ms motion.$ease-out forwards; + animation-delay: calc(var(--check-delay, 0ms) + 260ms); +} + +@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..ce89361bee --- /dev/null +++ b/src/app/common/celebrate/animated-check.component.ts @@ -0,0 +1,38 @@ +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.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); + + /** 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/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/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; + } +} From 033ca4d72c0a8a687a5adae33bb59d603889fddc Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:52:34 +1000 Subject: [PATCH 20/88] feat(tasks): confirm a Ready for Feedback submission with a short success moment When a student's submission lands, a small confirmation draws a check at the bottom of the screen and leaves after 2.5s, or on click or Escape. The copy follows the task dates: on time before the target date, a calmer Ready for Feedback after it, and a neutral note after the due date. A move from Fix and Resubmit reads as a resubmission. It replaces the success snackbar for that case only, and staff never see it. --- src/app/api/models/task.ts | 29 +++- .../submission-celebration.component.html | 22 +++ .../submission-celebration.component.scss | 156 ++++++++++++++++++ .../submission-celebration.component.ts | 37 +++++ .../submission-celebration.service.ts | 134 +++++++++++++++ .../celebrate/submission-timing.spec.ts | 139 ++++++++++++++++ src/app/common/celebrate/submission-timing.ts | 96 +++++++++++ 7 files changed, 612 insertions(+), 1 deletion(-) 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 diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 5187c827a6..f08984d037 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -4,6 +4,7 @@ import {HttpClient} from '@angular/common/http'; import {LOCALE_ID} from '@angular/core'; import {Observable, finalize, firstValueFrom, map} from 'rxjs'; import {AppInjector} from 'src/app/app-injector'; +import {SubmissionCelebrationService} from 'src/app/common/celebrate/submission-celebration.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {GradeTaskModalService} from 'src/app/tasks/modals/grade-task-modal/grade-task-modal.service'; @@ -124,6 +125,9 @@ export class Task extends Entity { suggestedTaskStatus; + /** Status held before the student's current submission began. Cleared once it settles. */ + private statusBeforeSubmission?: TaskStatusEnum; + private _unit: Unit; constructor(data?: Project | Unit) { @@ -978,6 +982,9 @@ export class Task extends Entity { isTestSubmission: boolean = false, ) { const oldStatus = this.status; + // A new submission remembers where it came from, so a resubmission reads as one. + // New evidence and test submissions are not status changes and are not celebrated. + this.statusBeforeSubmission = !reuploadEvidence && !isTestSubmission ? oldStatus : undefined; if (!isTestSubmission) { this.status = status; @@ -987,6 +994,7 @@ export class Task extends Entity { const modal = uploadModal.show(this, reuploadEvidence, isTestSubmission); // Modal failed to present if (!modal) { + this.statusBeforeSubmission = undefined; if (!isTestSubmission) { this.status = oldStatus; } @@ -1000,6 +1008,7 @@ export class Task extends Entity { }, // Grade was not selected (modal was dismissed) (_dismissed) => { + this.statusBeforeSubmission = undefined; if (!isTestSubmission) { this.status = oldStatus; } @@ -1021,9 +1030,18 @@ export class Task extends Entity { ); } + const previousStatus = this.statusBeforeSubmission; + this.statusBeforeSubmission = undefined; + const celebrated = + submissionCompleted && + expectedStatus === 'ready_for_feedback' && + previousStatus !== undefined && + this.celebrateSubmission(previousStatus); + if (this.status !== expectedStatus) { alerts.message(`Status changed to ${this.statusLabel()}.`, 4000); - } else { + } else if (!celebrated) { + // The submission confirmation already says this, so it replaces the snackbar. alerts.success(`Status changed to ${this.statusLabel()}.`); } this.getSubmissionDetails().subscribe(); @@ -1031,6 +1049,14 @@ export class Task extends Entity { taskService.notifyTransitionComplete(this, submissionCompleted); } + private celebrateSubmission(previousStatus: TaskStatusEnum): boolean { + try { + return AppInjector.get(SubmissionCelebrationService).celebrate(this, previousStatus); + } catch { + return false; + } + } + public async markAsDiscussed(reasonText?: string) { const alerts: AlertService = AppInjector.get(AlertService); const taskService: TaskService = AppInjector.get(TaskService); @@ -1152,6 +1178,7 @@ export class Task extends Entity { this.project.taskCache.delete(this.definition.abbreviation); this.project.taskCache.add(this); } + this.statusBeforeSubmission = submissionCompleted ? oldStatus : undefined; this.processTaskStatusChange(status, alerts, submissionCompleted); taskService.notifyStatusChange(this); }, 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..20fb8a6276 --- /dev/null +++ b/src/app/common/celebrate/submission-celebration.component.scss @@ -0,0 +1,156 @@ +@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); + box-shadow: + 0 1px 2px rgb(0 0 0 / 8%), + 0 8px 24px rgb(0 0 0 / 14%); + 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.`, + }; + } +} From 80082a719800c0557fd776a6899941730bc25d7a Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:52:39 +1000 Subject: [PATCH 21/88] feat(dashboard): show tasks signed off since the student last opened the unit Opening a project dashboard compares task statuses with the ones stored on the last visit. Newly completed tasks open a centred dialog with a staggered list, the progress toward the target grade moving from its old share to the new one, and a quieter list of tasks now ready to discuss, needing changes or with new feedback. A first visit records silently, staff and the portfolio view never check, and ?celebrate=preview replays it in a development build without touching storage. --- .../milestone-celebration.service.ts | 255 +++++++++++++++++ .../celebrate/milestone-dialog.component.html | 95 +++++++ .../celebrate/milestone-dialog.component.scss | 269 ++++++++++++++++++ .../milestone-dialog.component.spec.ts | 213 ++++++++++++++ .../celebrate/milestone-dialog.component.ts | 93 ++++++ .../project-dashboard.component.spec.ts | 73 +++++ .../project-dashboard.component.ts | 49 ++++ 7 files changed, 1047 insertions(+) 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 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..20ad726fa7 --- /dev/null +++ b/src/app/common/celebrate/milestone-celebration.service.ts @@ -0,0 +1,255 @@ +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 task weighting that is 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}; + } + + const anyWeighted = targets.some((definition) => definition.weighting > 0); + const weight = (definition: {weighting: number}) => + anyWeighted ? Math.max(0, definition.weighting || 0) : 1; + const total = targets.reduce((sum, definition) => sum + weight(definition), 0); + if (total <= 0) { + return {from: 0, to: 0}; + } + + const statusOf = (id: number): TaskStatusEnum | undefined => + project.findTaskForDefinition(id)?.status; + + const done = targets + .filter((definition) => statusOf(definition.id) === 'complete') + .reduce((sum, definition) => sum + weight(definition), 0); + + const before = previous + ? targets + .filter((definition) => previous[String(definition.id)] === 'complete') + .reduce((sum, definition) => sum + weight(definition), 0) + : done - + targets + .filter((definition) => completedSet.has(definition.id)) + .reduce((sum, definition) => sum + weight(definition), 0); + + 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..670dcb6fe0 --- /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(25); + expect(data?.progressTo).toBe(50); + 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(75); + 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/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts index 4e091a10a6..f9cfc2b19f 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts @@ -188,6 +188,79 @@ describe('ProjectDashboardComponent route reuse', () => { component.ngOnDestroy(); }); + describe('signed off check', () => { + const build = (options: {view?: boolean; taskSelectionUrlBase?: unknown[] | null} = {}) => { + const navigate = vi.fn().mockResolvedValue(true); + const checkProject = vi.fn().mockResolvedValue(options.view ? 'view' : null); + const component = new ProjectDashboardComponent( + {} as UserService, + { + get: ( + params: {id: number}, + callbacks: {mappingCompleteCallback: (project: Project) => void}, + ) => { + callbacks.mappingCompleteCallback(firstProject); + return of(firstProject); + }, + } as unknown as ProjectService, + {taskSubmissionCompleted$: new Subject()} as unknown as TaskService, + {get: () => of(firstUnit)} as unknown as UnitService, + {setView: vi.fn()} as unknown as GlobalStateService, + { + parent: { + data: of({project: firstProject}), + snapshot: {paramMap: convertToParamMap({projectId: firstProject.id})}, + }, + } as unknown as ActivatedRoute, + {observe: () => of({matches: false, breakpoints: {}})} as unknown as BreakpointObserver, + {navigate} as unknown as Router, + undefined, + undefined, + {checkProject} as never, + ); + component.taskSelectionUrlBase = options.taskSelectionUrlBase ?? null; + component.project$ = of(firstProject); + return {component, checkProject, navigate}; + }; + + it('checks once when the project has loaded, without a preview by default', () => { + const {component, checkProject} = build(); + + component.ngOnInit(); + component.retryProjectLoad(); + + expect(checkProject).toHaveBeenCalledTimes(1); + expect(checkProject).toHaveBeenCalledWith(firstProject, {preview: false}); + component.ngOnDestroy(); + }); + + it('never checks inside the staff portfolio view', () => { + const {component, checkProject} = build({taskSelectionUrlBase: ['/units', 1]}); + + component.ngOnInit(); + + expect(checkProject).not.toHaveBeenCalled(); + component.ngOnDestroy(); + }); + + it('filters the task list to completed tasks when asked to view them', async () => { + const {component, navigate} = build({view: true}); + + component.ngOnInit(); + await Promise.resolve(); + await Promise.resolve(); + + expect(navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: {taskStatus: 'complete', taskView: 'tasks'}, + queryParamsHandling: 'merge', + }), + ); + component.ngOnDestroy(); + }); + }); + // A wrong or stale link, or no access, left the page on its skeleton forever. it('shows a failed load instead of the skeleton, and loads again on retry', () => { const projectGet = vi diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts index 999e737359..da13e99785 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts @@ -1,3 +1,4 @@ +import {environment} from 'src/environments/environment'; import {BreakpointObserver} from '@angular/cdk/layout'; import { ChangeDetectionStrategy, @@ -8,6 +9,7 @@ import { OnInit, Output, ViewChild, + isDevMode, } from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {BehaviorSubject, Observable, Subject, filter, map, takeUntil} from 'rxjs'; @@ -22,6 +24,7 @@ import {ProjectService} from 'src/app/api/services/project.service'; import {TaskService} from 'src/app/api/services/task.service'; import {UnitService} from 'src/app/api/services/unit.service'; import {UserService} from 'src/app/api/services/user.service'; +import {MilestoneCelebrationService} from 'src/app/common/celebrate/milestone-celebration.service'; import {ConversationLandingService} from 'src/app/tasks/task-comments-viewer/conversation-landing.service'; import {FUnitTaskListComponent} from 'src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component'; import {GlobalStateService, ViewType} from '../../index/global-state.service'; @@ -69,6 +72,9 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { projectTasks = []; + private milestoneCheckedProjectId: number | null = null; + private destroyed = false; + constructor( private currentUser: UserService, private projectService: ProjectService, @@ -80,6 +86,7 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { private angularRouter: Router, private notificationFeedbackIntents?: NotificationFeedbackRouteIntentService, private conversationLanding?: ConversationLandingService, + private milestones?: MilestoneCelebrationService, ) {} /** @@ -252,6 +259,7 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.destroyed = true; this.projectLoadCancel$.next(); this.projectLoadCancel$.complete(); this.destroy$.next(); @@ -457,5 +465,46 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { this.projectReady = true; this.globalStateService.setView(ViewType.PROJECT, project); this.projectSubject.next(project); + this.checkMilestones(project); + } + + /** + * Once per opened project, shows the signed off dialog when tasks became complete + * since the student last looked. The staff portfolio view embeds this page and + * never checks. The service itself also refuses anyone who is not the student. + */ + private checkMilestones(project: Project): void { + if ( + !this.milestones || + this.taskSelectionUrlBase || + this.milestoneCheckedProjectId === project.id + ) { + return; + } + this.milestoneCheckedProjectId = project.id; + + void this.milestones + .checkProject(project, {preview: this.milestonePreviewRequested()}) + .then((result) => { + if (result !== 'view' || this.destroyed || this.activeProjectId !== project.id) { + return; + } + + void this.angularRouter.navigate([], { + relativeTo: this.route, + queryParams: {taskStatus: 'complete', taskView: 'tasks'}, + queryParamsHandling: 'merge', + }); + }) + .catch(() => undefined); + } + + /** `?celebrate=preview` replays the dialog locally. A production build ignores it. */ + private milestonePreviewRequested(): boolean { + return ( + isDevMode() && + environment.production === false && + this.route.snapshot?.queryParamMap?.get('celebrate') === 'preview' + ); } } From bb28989715987a0eb8eec2d9cf0055fe2f14da2d Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:52:52 +1000 Subject: [PATCH 22/88] feat(unit-hub): render announcement and session text as simple markdown The details dialog renders bodies and descriptions through a small standalone pipe that parses with marked and sanitises with DOMPurify: basic formatting only, no images, frames, styles or handlers, and links open in a new tab with noopener. Typed HTML still shows as text. Long bodies sit at a 68ch measure and clamp at 40vh behind a Show full toggle. Feed cards show a plain text excerpt, and the staff editor gets a formatting hint with a Write and Preview toggle. The API still stores plain text, so Teams sync and emails show the raw markdown characters. --- src/app/unit-hub/_hub-card.scss | 79 +++++++++++++ src/app/unit-hub/hub-markdown.spec.ts | 64 +++++++++++ src/app/unit-hub/hub-markdown.ts | 106 ++++++++++++++++++ .../unit-hub/unit-hub-details.component.html | 42 ++++++- .../unit-hub/unit-hub-details.component.scss | 17 ++- .../unit-hub-details.component.spec.ts | 25 +++++ .../unit-hub/unit-hub-details.component.ts | 36 +++++- src/app/unit-hub/unit-hub.component.html | 66 ++++++++++- src/app/unit-hub/unit-hub.component.scss | 67 +++++++++++ src/app/unit-hub/unit-hub.component.spec.ts | 27 +++++ src/app/unit-hub/unit-hub.component.ts | 8 ++ 11 files changed, 528 insertions(+), 9 deletions(-) create mode 100644 src/app/unit-hub/hub-markdown.spec.ts create mode 100644 src/app/unit-hub/hub-markdown.ts diff --git a/src/app/unit-hub/_hub-card.scss b/src/app/unit-hub/_hub-card.scss index 8bb0473107..0dc23170ba 100644 --- a/src/app/unit-hub/_hub-card.scss +++ b/src/app/unit-hub/_hub-card.scss @@ -114,3 +114,82 @@ } } } + +// Rendered markdown for announcement bodies and session descriptions. +// Links and text use theme tokens, so it reads the same in light and dark. +// Include it on the element that receives the rendered HTML. +@mixin markdown { + max-width: 68ch; + font-size: 0.95rem; + line-height: 1.65; + overflow-wrap: anywhere; + + // the markup comes from [innerHTML], so it carries no encapsulation attribute + ::ng-deep { + :where(p, ul, ol, blockquote, pre, h3, h4) { + margin: 0 0 0.75em; + } + > :last-child { + margin-bottom: 0; + } + :where(h3, h4) { + margin-top: 1.1em; + color: var(--ot-color-text); + font-weight: 650; + line-height: 1.35; + } + > :where(h3, h4):first-child { + margin-top: 0; + } + h3 { + font-size: 1.05rem; + } + h4 { + font-size: 0.98rem; + } + :where(ul, ol) { + padding-left: 1.4em; + } + li + li { + margin-top: 0.25em; + } + a { + color: var(--ot-color-link); + text-decoration: underline; + text-underline-offset: 3px; + } + strong { + font-weight: 650; + } + blockquote { + padding: 0.5em 0.9em; + border-left: 3px solid var(--ot-color-link); + border-radius: var(--ot-radius-xs); + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); + } + code { + padding: 0.1em 0.35em; + border-radius: var(--ot-radius-xs); + background: var(--ot-color-surface-raised); + font-size: 0.88em; + } + pre { + padding: 0.75em 1em; + overflow-x: auto; + border-radius: var(--ot-radius-sm); + background: var(--ot-color-surface-raised); + white-space: pre; + } + pre code { + padding: 0; + background: none; + } + hr { + height: 0; + margin: 1em 0; + border: 0; + border-top: 1px solid var(--hub-card-border); + } + } +} diff --git a/src/app/unit-hub/hub-markdown.spec.ts b/src/app/unit-hub/hub-markdown.spec.ts new file mode 100644 index 0000000000..cebc3b816d --- /dev/null +++ b/src/app/unit-hub/hub-markdown.spec.ts @@ -0,0 +1,64 @@ +import {describe, expect, it} from 'vitest'; +import {hubMarkdownToText, renderHubMarkdown} from './hub-markdown'; + +const dom = (html: string) => { + const holder = document.createElement('div'); + holder.innerHTML = html; + return holder; +}; + +describe('Unit Hub markdown', () => { + it('renders basic formatting', () => { + const html = dom( + renderHubMarkdown('# Week 3\n\n**Bring** *questions*\n\n- one\n- two\n\n> note\n\n`code`'), + ); + expect(html.querySelector('h3')?.textContent).toBe('Week 3'); + expect(html.querySelector('h1, h2')).toBeNull(); + expect(html.querySelector('strong')?.textContent).toBe('Bring'); + expect(html.querySelector('em')?.textContent).toBe('questions'); + expect(html.querySelectorAll('li')).toHaveLength(2); + expect(html.querySelector('blockquote')).not.toBeNull(); + expect(html.querySelector('code')?.textContent).toBe('code'); + }); + + it('strips scripts, images, frames, styles and event handlers', () => { + const html = dom( + renderHubMarkdown( + 'Hi \n\n![pic](https://example.test/a.png)\n\n\n\n

x

', + ), + ); + expect(html.querySelector('script, img, iframe, [style], [onclick], [onerror]')).toBeNull(); + // typed HTML is kept as the characters typed + expect(html.textContent).toContain(''); + expect(html.textContent).toContain('pic'); + }); + + it('opens links in a new tab and drops unsafe schemes', () => { + const html = dom( + renderHubMarkdown( + '[Guide](https://example.test/guide) [Bad](javascript:alert(1)) [Mail](mailto:help@example.test)', + ), + ); + const links = Array.from(html.querySelectorAll('a')); + const guide = links.find((link) => link.textContent === 'Guide'); + expect(guide?.getAttribute('href')).toBe('https://example.test/guide'); + expect(guide?.getAttribute('target')).toBe('_blank'); + expect(guide?.getAttribute('rel')).toBe('noopener noreferrer'); + expect( + links.find((link) => link.textContent === 'Bad')?.getAttribute('href') ?? null, + ).toBeNull(); + expect(html.innerHTML).not.toContain('javascript:'); + for (const link of links) { + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + } + }); + + it('turns markdown into plain excerpt text without formatting symbols', () => { + const text = hubMarkdownToText( + '## Heading\n\n**Bold** and *italic* with `code`\n\n- first\n- second\n\n[Link](https://example.test)', + ); + expect(text).toBe('Heading Bold and italic with code first second Link'); + expect(text).not.toMatch(/[*#`[\]]/); + expect(hubMarkdownToText(null)).toBe(''); + }); +}); diff --git a/src/app/unit-hub/hub-markdown.ts b/src/app/unit-hub/hub-markdown.ts new file mode 100644 index 0000000000..40fdc8da1e --- /dev/null +++ b/src/app/unit-hub/hub-markdown.ts @@ -0,0 +1,106 @@ +import DOMPurify from 'dompurify'; +import {Marked, Tokens} from 'marked'; +import {Pipe, PipeTransform, inject} from '@angular/core'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; + +/** Basic formatting only. No images, frames, styles or event handlers. */ +const ALLOWED_TAGS = [ + 'p', + 'br', + 'strong', + 'em', + 'ul', + 'ol', + 'li', + 'h3', + 'h4', + 'blockquote', + 'code', + 'pre', + 'a', + 'hr', +]; + +const escapeHtml = (text: string) => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +// A private instance, so these options never change the app-wide marked pipe. +const hubMarked = new Marked({ + gfm: true, + breaks: true, + async: false, + walkTokens(token) { + // headings sit under the dialog title, so the scale starts at h3 + if (token.type === 'heading') { + const heading = token as Tokens.Heading; + heading.depth = Math.min(4, Math.max(3, heading.depth)); + } + }, + renderer: { + // Bodies are stored as plain text. Typed HTML shows as the characters typed. + html({text}: Tokens.HTML | Tokens.Tag) { + return escapeHtml(text); + }, + image({text}: Tokens.Image) { + return escapeHtml(text); + }, + }, +}); + +function sanitizedFragment(markdown: string | null | undefined): DocumentFragment { + const html = hubMarked.parse(markdown ?? '', {async: false}) as string; + const fragment = DOMPurify.sanitize(html, { + ALLOWED_TAGS, + ALLOWED_ATTR: ['href'], + ALLOWED_URI_REGEXP: /^(?:https:|mailto:)/i, + RETURN_DOM_FRAGMENT: true, + }); + for (const link of Array.from(fragment.querySelectorAll('a'))) { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } + return fragment; +} + +/** Markdown to sanitised HTML with basic formatting and links that open in a new tab. */ +export function renderHubMarkdown(markdown: string | null | undefined): string { + const holder = document.createElement('div'); + holder.appendChild(sanitizedFragment(markdown)); + return holder.innerHTML; +} + +const BLOCKS = 'p, li, h3, h4, blockquote, pre, hr'; + +/** Markdown to one line of plain text, for card excerpts. */ +export function hubMarkdownToText(markdown: string | null | undefined): string { + const fragment = sanitizedFragment(markdown); + for (const br of Array.from(fragment.querySelectorAll('br'))) { + br.replaceWith(' '); + } + for (const block of Array.from(fragment.querySelectorAll(BLOCKS))) { + block.append(' '); + } + return (fragment.textContent ?? '').replace(/\s+/g, ' ').trim(); +} + +@Pipe({name: 'hubMarkdown', standalone: true}) +export class HubMarkdownPipe implements PipeTransform { + private readonly sanitizer = inject(DomSanitizer); + + transform(markdown: string | null | undefined): SafeHtml { + // DOMPurify has already reduced this to the allowed tags and attributes above. + return this.sanitizer.bypassSecurityTrustHtml(renderHubMarkdown(markdown)); + } +} + +@Pipe({name: 'hubPlainText', standalone: true}) +export class HubPlainTextPipe implements PipeTransform { + transform(markdown: string | null | undefined): string { + return hubMarkdownToText(markdown); + } +} diff --git a/src/app/unit-hub/unit-hub-details.component.html b/src/app/unit-hub/unit-hub-details.component.html index ee2b861aa4..4274735242 100644 --- a/src/app/unit-hub/unit-hub-details.component.html +++ b/src/app/unit-hub/unit-hub-details.component.html @@ -68,7 +68,18 @@

{{ title }}

-

{{ announcement.body }}

+
+ @if (bodyCollapsible) { + + }
@if (safeUrl(announcement.source_url)) { } }

{{ status }}

+ + + diff --git a/src/app/unit-hub/unit-hub-details.component.scss b/src/app/unit-hub/unit-hub-details.component.scss index 48d1c648e7..ec37ec0783 100644 --- a/src/app/unit-hub/unit-hub-details.component.scss +++ b/src/app/unit-hub/unit-hub-details.component.scss @@ -99,10 +99,19 @@ small { @include hub.label; margin-bottom: 8px; } -.full-content p { - white-space: pre-wrap; - margin: 0; - font-size: 0.95rem; +// markdown body: a comfortable measure and rhythm for long announcements +.markdown-body { + @include hub.markdown; +} +.markdown-body.is-clamped { + max-height: 40vh; + overflow: hidden; + // fades the last lines out so it reads as continuing; a mask, so no colour is involved + -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent); + mask-image: linear-gradient(to bottom, black 70%, transparent); +} +.body-toggle { + margin-top: 12px; } .detail-notice, .cancelled-notice { diff --git a/src/app/unit-hub/unit-hub-details.component.spec.ts b/src/app/unit-hub/unit-hub-details.component.spec.ts index 5c639f0dad..a654836a21 100644 --- a/src/app/unit-hub/unit-hub-details.component.spec.ts +++ b/src/app/unit-hub/unit-hub-details.component.spec.ts @@ -121,4 +121,29 @@ describe('Unit Hub full details', () => { expect(calendarSettings).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce(); }); + it('renders a markdown body and clamps a long one behind an expand toggle', async () => { + const announcement = { + ...unitHubDemo(new Date('2026-09-14T06:00:00Z')).announcements[0], + body: '**Bold** start\n\n[Guide](https://example.test/guide)', + }; + const {component, fixture, element} = await render({announcement}); + const body = element.querySelector('#unit-hub-detail-body') as HTMLElement; + expect(body.querySelector('strong')?.textContent).toBe('Bold'); + expect(body.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer'); + expect(element.querySelector('.body-toggle')).toBeNull(); + + Object.defineProperty(body, 'scrollHeight', {configurable: true, value: 5000}); + component.measureBody(); + fixture.detectChanges(); + const toggle = element.querySelector('.body-toggle') as HTMLButtonElement; + expect(toggle.textContent).toContain('Show full announcement'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBe('unit-hub-detail-body'); + expect(body.classList).toContain('is-clamped'); + toggle.click(); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(body.classList).not.toContain('is-clamped'); + expect(toggle.textContent).toContain('Show less'); + }); }); diff --git a/src/app/unit-hub/unit-hub-details.component.ts b/src/app/unit-hub/unit-hub-details.component.ts index e07c277051..69ae638ce7 100644 --- a/src/app/unit-hub/unit-hub-details.component.ts +++ b/src/app/unit-hub/unit-hub-details.component.ts @@ -1,8 +1,19 @@ import {CommonModule} from '@angular/common'; -import {ChangeDetectionStrategy, Component, Inject} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + Injector, + ViewChild, + afterNextRender, + 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 {HubMarkdownPipe} from './hub-markdown'; import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; import {safeHttpsUrl} from './unit-hub-calendar'; import {LearningSession, SESSION_KINDS, UnitAnnouncement} from './unit-hub.models'; @@ -28,6 +39,7 @@ export interface UnitHubDetailsData { MatButtonModule, MatDialogModule, MatIconModule, + HubMarkdownPipe, TeamsMeetingComposerComponent, ], templateUrl: './unit-hub-details.component.html', @@ -39,12 +51,34 @@ export class UnitHubDetailsComponent { readonly safeUrl = safeHttpsUrl; content: UnitHubDetailsData | null; status = ''; + /** A body taller than this share of the viewport starts clamped behind a toggle. */ + readonly clampShare = 0.4; + bodyCollapsible = false; + bodyExpanded = false; + @ViewChild('bodyText') private bodyText?: ElementRef; + private readonly changes = inject(ChangeDetectorRef); constructor( @Inject(MAT_DIALOG_DATA) data: UnitHubDetailsData, private dialogRef: MatDialogRef, ) { this.content = data; + afterNextRender(() => this.measureBody(), {injector: inject(Injector)}); + } + + /** Decides whether the body is long enough to clamp. Its full height is kept while clamped. */ + measureBody(): void { + const body = this.bodyText?.nativeElement; + const limit = window.innerHeight * this.clampShare; + const collapsible = !!body && body.scrollHeight > limit; + if (collapsible !== this.bodyCollapsible) { + this.bodyCollapsible = collapsible; + this.changes.detectChanges(); + } + } + + toggleBody(): void { + this.bodyExpanded = !this.bodyExpanded; } get title(): string { diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index b803bca8fe..99fcc5d096 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -234,13 +234,44 @@

+
+

+ Supports simple formatting: **bold**, *italic*, lists, links +

+
+ + +
+
+ @if (announcementPreview) { +
+ }

+
+

+ Supports simple formatting: **bold**, *italic*, lists, links +

+
+ + +
+
+ @if (sessionPreview) { +
+ }

@@ -707,7 +769,7 @@

{{ announcement.title }}

-

{{ announcement.body }}

+

{{ announcement.body | hubPlainText }}

@@ -886,7 +892,9 @@

} - {{ kindLabel(session.kind) }} @@ -907,9 +915,6 @@

> }

- @if (session.cancelled) { -

Cancelled · this session will not run

- } @if (session.description) {

{{ session.description | hubPlainText }}

} diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index 484e7ebe19..d4b16fb2e8 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -377,10 +377,6 @@ mat-icon { color: var(--ot-color-text); border: 1px solid var(--hub-card-border); } -.chip-cancelled { - background: var(--ot-color-error); - color: var(--ot-color-on-error); -} .kind-chip { background: var(--ot-status-ready-for-feedback); color: var(--ot-status-ready-for-feedback-on); @@ -392,6 +388,12 @@ mat-icon { background: var(--ot-status-need-help); color: var(--ot-status-need-help-on); } + // cancelled items drop the type colour, so the cancelled state is the only colour signal + &[data-tone='cancelled'] { + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); + box-shadow: inset 0 0 0 1px var(--hub-card-border); + } } .live-badge { background: var(--ot-status-complete); @@ -499,19 +501,17 @@ mat-icon { margin: 0; color: var(--ot-color-text-muted); } -.is-cancelled { - border-style: dashed; - box-shadow: none; - h4 { - text-decoration: line-through; - text-decoration-thickness: 1px; +// Cancelled: a sunken card with an error edge and accent bar, so it recedes next to +// active ones and still reads clearly. The banner carries the state in words. +.session-card.is-cancelled { + @include hub.cancelled-card; + .card-preview, + .fact { + color: var(--ot-color-text-muted); } } -.cancelled-label { - // Pulled toward the text colour so it clears 4.5:1 on the surface in both themes. - color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text)); - font-weight: 650; - font-size: 0.86rem; +.cancelled-banner { + @include hub.cancelled-banner; } .help-text { margin: var(--hub-space-2) 0; diff --git a/src/app/unit-hub/unit-hub.component.spec.ts b/src/app/unit-hub/unit-hub.component.spec.ts index 51c99d7bb6..de4badba44 100644 --- a/src/app/unit-hub/unit-hub.component.spec.ts +++ b/src/app/unit-hub/unit-hub.component.spec.ts @@ -532,4 +532,28 @@ describe('Unit Hub route, forms and rendered content', () => { expect(preview.getAttribute('aria-pressed')).toBe('true'); expect(component.announcementForm.controls.body.value).toBe('**Bold** text'); }); + it('marks cancelled sessions with one banner, a neutral type chip and sorts them last in their day', async () => { + const data = feed(); + const active = data.sessions[0]; + data.sessions = [ + {...active, id: 900, title: 'Cancelled one', cancelled: true}, + {...active, id: 901, title: 'Active one', cancelled: false}, + ]; + service.feed.mockReturnValue(of(data)); + const {element} = await open(); + const cards = Array.from(element.querySelectorAll('.session-card')); + expect(cards.map((card) => card.querySelector('.details-title').textContent.trim())).toEqual([ + 'Active one', + 'Cancelled one', + ]); + const cancelled = cards[1]; + expect(cancelled.querySelector('.cancelled-banner')?.textContent).toContain( + 'Cancelled: this session will not run', + ); + expect(cancelled.querySelector('h4 .visually-hidden')?.textContent).toContain('Cancelled'); + expect(cancelled.querySelector('.kind-chip')?.getAttribute('data-tone')).toBe('cancelled'); + expect(cancelled.querySelector('.details-link')).not.toBeNull(); + expect(cancelled.querySelector('a[href^="https://calendar.google.com"]')).toBeNull(); + expect(cards[0].querySelector('.cancelled-banner')).toBeNull(); + }); }); diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index f1a82ed868..bffc9c004a 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -238,6 +238,10 @@ export class UnitHubComponent implements OnInit, OnDestroy { } group.sessions.push(session); } + // cancelled sessions follow the active ones on the same day; the sort is stable + for (const group of groups) { + group.sessions.sort((a, b) => Number(a.cancelled) - Number(b.cancelled)); + } return groups; } startAnnouncement(): void { From f25c25fe3cfc200b3dfe6ca3a254ffa381a3a194 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:55:22 +1000 Subject: [PATCH 24/88] fix(dashboard): open the task list when a status card filters it --- .../project-dashboard.component.html | 1 + .../project-dashboard.component.spec.ts | 15 +++++++++++++++ .../project-dashboard.component.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html index 74c79479bc..18b28228af 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html @@ -184,6 +184,7 @@ [(fullscreenPanel)]="fullscreenPanel" > { ); component.ngOnDestroy(); }); + + it('opens a collapsed task list when a status card filters it', () => { + vi.useFakeTimers(); + const {component} = build({view: true}); + const expandFromRail = vi.fn(); + (component as unknown as {taskListPanel: {expandFromRail: () => void}}).taskListPanel = { + expandFromRail, + }; + + (component as unknown as {revealTaskList: () => void}).revealTaskList(); + vi.runAllTimers(); + + expect(expandFromRail).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); }); // A wrong or stale link, or no access, left the page on its skeleton forever. diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts index da13e99785..008704237a 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts @@ -25,6 +25,7 @@ import {TaskService} from 'src/app/api/services/task.service'; import {UnitService} from 'src/app/api/services/unit.service'; import {UserService} from 'src/app/api/services/user.service'; import {MilestoneCelebrationService} from 'src/app/common/celebrate/milestone-celebration.service'; +import {PanelComponent} from 'src/app/common/panel-layout/panel.component'; import {ConversationLandingService} from 'src/app/tasks/task-comments-viewer/conversation-landing.service'; import {FUnitTaskListComponent} from 'src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component'; import {GlobalStateService, ViewType} from '../../index/global-state.service'; @@ -38,6 +39,7 @@ import {GlobalStateService, ViewType} from '../../index/global-state.service'; }) export class ProjectDashboardComponent implements OnInit, OnDestroy { @ViewChild('leftPanel') private leftPanel?: FUnitTaskListComponent; + @ViewChild('taskListPanel') private taskListPanel?: PanelComponent; @Input() public project$: Observable; @Input() public defaultTaskListCollapsed = false; @@ -218,6 +220,7 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { // than leaving the phone on the overview or stale task pane. this.selectedTaskDefinition$.next(null); this.mobilePane = 'task'; + this.revealTaskList(); } else if (wasTaskFilterNavigation) { // The companion view marker lets browser Back restore the Overview pane, // while a user-cleared status retains taskView=tasks and stays in Tasks. @@ -258,6 +261,15 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { window.dispatchEvent(new Event('resize')); } + /** + * A status card filters the task list, so open that list if it is collapsed or + * railed for space. Deferred a tick so it also works on first load, before the + * panel exists. + */ + private revealTaskList(): void { + setTimeout(() => this.taskListPanel?.expandFromRail()); + } + ngOnDestroy(): void { this.destroyed = true; this.projectLoadCancel$.next(); From 5e6e98d6daef187a9ba1554be42c8a280d465c82 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:55:25 +1000 Subject: [PATCH 25/88] fix(dashboard): count signed off progress by task so it matches the dashboard The progress dashboard reports tasks complete by count, so a weighted share in the dialog showed a different number beside it. --- .../milestone-celebration.service.ts | 26 +++++-------------- .../milestone-dialog.component.spec.ts | 6 ++--- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/app/common/celebrate/milestone-celebration.service.ts b/src/app/common/celebrate/milestone-celebration.service.ts index 20ad726fa7..6c85e9c0ef 100644 --- a/src/app/common/celebrate/milestone-celebration.service.ts +++ b/src/app/common/celebrate/milestone-celebration.service.ts @@ -201,7 +201,7 @@ export class MilestoneCelebrationService { } /** - * Share of the target grade's task weighting that is signed off, before and after. + * 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. */ @@ -215,29 +215,17 @@ export class MilestoneCelebrationService { return {from: 0, to: 0}; } - const anyWeighted = targets.some((definition) => definition.weighting > 0); - const weight = (definition: {weighting: number}) => - anyWeighted ? Math.max(0, definition.weighting || 0) : 1; - const total = targets.reduce((sum, definition) => sum + weight(definition), 0); - if (total <= 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') - .reduce((sum, definition) => sum + weight(definition), 0); + const done = targets.filter((definition) => statusOf(definition.id) === 'complete').length; const before = previous - ? targets - .filter((definition) => previous[String(definition.id)] === 'complete') - .reduce((sum, definition) => sum + weight(definition), 0) - : done - - targets - .filter((definition) => completedSet.has(definition.id)) - .reduce((sum, definition) => sum + weight(definition), 0); + ? 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), diff --git a/src/app/common/celebrate/milestone-dialog.component.spec.ts b/src/app/common/celebrate/milestone-dialog.component.spec.ts index 670dcb6fe0..92807421a8 100644 --- a/src/app/common/celebrate/milestone-dialog.component.spec.ts +++ b/src/app/common/celebrate/milestone-dialog.component.spec.ts @@ -179,8 +179,8 @@ describe('MilestoneCelebrationService.prepare', () => { expect(data?.alsoChanged).toEqual([ {taskDefinitionId: 3, abbreviation: '3.1C', name: 'Sorting', label: 'Needs changes'}, ]); - expect(data?.progressFrom).toBe(25); - expect(data?.progressTo).toBe(50); + 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. @@ -206,7 +206,7 @@ describe('MilestoneCelebrationService.prepare', () => { }); expect(data?.completed.length).toBe(2); - expect(data?.progressTo).toBe(75); + expect(data?.progressTo).toBe(67); expect(data?.progressFrom).toBe(0); expect(seen.read(5, 12)).toBeNull(); }); From 3269ad2a02dda88ad499315673ef151673fb635f Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:55:58 +1000 Subject: [PATCH 26/88] fix(tasks): size and inset the status picker icon, one focus ring --- .../unit-task-list.component.scss | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss index 178d8a9d51..ecc2e081fa 100644 --- a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss @@ -137,7 +137,8 @@ $my-palette: mat.m2-define-palette($md-formatif); border: 1px solid var(--ot-color-border); border-radius: 999px; background-color: var(--ot-color-surface); - padding: 0 0.75rem 0 0.5rem; + // the 24px status circle sits 8px in from the pill edge, same as the text gap + padding: 0 0.75rem 0 8px; color: var(--ot-color-text); font-size: 0.875rem; @@ -145,10 +146,13 @@ $my-palette: mat.m2-define-palette($md-formatif); --mat-select-enabled-arrow-color: var(--ot-color-text-muted); --mat-select-focused-arrow-color: var(--ot-color-primary); + // one focus ring: the pill border itself turns the focus colour, no second outline &:focus-visible, - &.mat-select-focused { - outline: 2px solid var(--ot-color-focus); - outline-offset: 1px; + &.mat-select-focused, + &.mat-mdc-select-focused { + outline: none; + border-color: var(--ot-color-focus); + box-shadow: 0 0 0 1px var(--ot-color-focus); } } @@ -171,9 +175,10 @@ $my-palette: mat.m2-define-palette($md-formatif); // 24px so a row of options reads as a list, not a column of badges. :host ::ng-deep .task-status-option__icon .status-chip, ::ng-deep .task-status-filter-panel .task-status-option__icon .status-chip { - width: 24px; - height: 24px; - margin: 0; + // !important beats the size-9 utility status-icon puts on its own element + width: 24px !important; + height: 24px !important; + margin: 0 !important; mat-icon { width: 14px; From d408e85ed72711bd66e4fdd9c213381c74e00056 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Tue, 15 Sep 2026 23:59:16 +1000 Subject: [PATCH 27/88] feat(unit-hub): highlight sessions happening now or soon, pin Up next and open deep links Session cards show Happening now with a gently pulsing dot and a success accent, Starts in N min with a primary accent inside the hour, or Today, driven by one minute clock that stops on destroy. Cancelled sessions get no highlight. A live session's Join becomes the filled action, and an Up next card at the top of Upcoming shows the next active session with its join and calendar actions. ?announcement= and ?session= open that item's details once the feed loads, mark an announcement read, and are then removed with replaceUrl without reloading the feed. A missing item shows a snackbar. The header gets a Get notified link to the notification settings on the profile page. --- src/app/unit-hub/session-timing.spec.ts | 55 +++++++++ src/app/unit-hub/session-timing.ts | 60 +++++++++ src/app/unit-hub/unit-hub.component.html | 128 +++++++++++++++++++- src/app/unit-hub/unit-hub.component.scss | 83 ++++++++++++- src/app/unit-hub/unit-hub.component.spec.ts | 84 ++++++++++++- src/app/unit-hub/unit-hub.component.ts | 107 +++++++++++++--- 6 files changed, 492 insertions(+), 25 deletions(-) create mode 100644 src/app/unit-hub/session-timing.spec.ts create mode 100644 src/app/unit-hub/session-timing.ts diff --git a/src/app/unit-hub/session-timing.spec.ts b/src/app/unit-hub/session-timing.spec.ts new file mode 100644 index 0000000000..16a6f16650 --- /dev/null +++ b/src/app/unit-hub/session-timing.spec.ts @@ -0,0 +1,55 @@ +import {describe, expect, it} from 'vitest'; +import {sessionTiming, upNextSession} from './session-timing'; + +const at = (iso: string) => Date.parse(iso); +const session = (start: string, end: string, cancelled = false, id = 1) => ({ + id, + start_at: new Date(start).toISOString(), + end_at: new Date(end).toISOString(), + cancelled, +}); + +describe('Unit Hub session timing', () => { + // local times, so "today" follows the device's day like the page does + const now = at('2026-09-16T10:00:00'); + + it('classifies happening now, starting soon, later today and cancelled', () => { + expect(sessionTiming(session('2026-09-16T09:30:00', '2026-09-16T10:30:00'), now)).toEqual({ + state: 'now', + }); + expect(sessionTiming(session('2026-09-16T10:25:00', '2026-09-16T11:00:00'), now)).toEqual({ + state: 'soon', + minutes: 25, + }); + expect(sessionTiming(session('2026-09-16T11:00:00', '2026-09-16T12:00:00'), now).minutes).toBe( + 60, + ); + expect(sessionTiming(session('2026-09-16T11:01:00', '2026-09-16T12:00:00'), now)).toEqual({ + state: 'today', + }); + expect(sessionTiming(session('2026-09-17T09:00:00', '2026-09-17T10:00:00'), now)).toEqual({ + state: 'none', + }); + // ended sessions and the exact end instant are not live + expect(sessionTiming(session('2026-09-16T09:00:00', '2026-09-16T10:00:00'), now).state).toBe( + 'none', + ); + expect( + sessionTiming(session('2026-09-16T09:30:00', '2026-09-16T10:30:00', true), now).state, + ).toBe('cancelled'); + expect( + sessionTiming(session('2026-09-16T10:10:00', '2026-09-16T11:00:00', true), now).state, + ).toBe('cancelled'); + }); + + it('picks the live or earliest upcoming session that is not cancelled for Up next', () => { + const ended = session('2026-09-16T08:00:00', '2026-09-16T09:00:00', false, 1); + const cancelledSoon = session('2026-09-16T10:05:00', '2026-09-16T11:00:00', true, 2); + const later = session('2026-09-17T09:00:00', '2026-09-17T10:00:00', false, 3); + const soon = session('2026-09-16T10:30:00', '2026-09-16T11:30:00', false, 4); + expect(upNextSession([ended, cancelledSoon, later, soon], now)?.id).toBe(4); + const live = session('2026-09-16T09:45:00', '2026-09-16T10:15:00', false, 5); + expect(upNextSession([later, soon, live], now)?.id).toBe(5); + expect(upNextSession([ended, cancelledSoon], now)).toBeNull(); + }); +}); diff --git a/src/app/unit-hub/session-timing.ts b/src/app/unit-hub/session-timing.ts new file mode 100644 index 0000000000..3f75be5734 --- /dev/null +++ b/src/app/unit-hub/session-timing.ts @@ -0,0 +1,60 @@ +import {LearningSession} from './unit-hub.models'; + +/** How far ahead a session counts as starting soon. */ +export const SOON_WINDOW_MS = 60 * 60 * 1000; + +export type SessionTimingState = 'cancelled' | 'now' | 'soon' | 'today' | 'none'; + +export interface SessionTiming { + state: SessionTimingState; + /** Whole minutes until the start, for a session starting soon. */ + minutes?: number; +} + +type Timed = Pick; + +const sameLocalDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +/** + * Where a session sits relative to now. Cancelled sessions never get a time highlight. + * now: start <= now < end. soon: starts within the next 60 minutes. today: later today. + */ +export function sessionTiming(session: Timed, now: number): SessionTiming { + if (session.cancelled) { + return {state: 'cancelled'}; + } + const start = Date.parse(session.start_at); + const end = Date.parse(session.end_at); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return {state: 'none'}; + } + if (start <= now && now < end) { + return {state: 'now'}; + } + if (start > now && start - now <= SOON_WINDOW_MS) { + return {state: 'soon', minutes: Math.max(1, Math.ceil((start - now) / 60000))}; + } + if (start > now && sameLocalDay(new Date(start), new Date(now))) { + return {state: 'today'}; + } + return {state: 'none'}; +} + +/** The next session to show: happening now, or the earliest one still to start. Never cancelled. */ +export function upNextSession(sessions: readonly T[], now: number): T | null { + let next: T | null = null; + for (const session of sessions) { + const start = Date.parse(session.start_at); + const end = Date.parse(session.end_at); + if (session.cancelled || !Number.isFinite(start) || !(end > now)) { + continue; + } + if (!next || start < Date.parse(next.start_at)) { + next = session; + } + } + return next; +} diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index 12a4a462b6..9b37cb74a9 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -64,6 +64,14 @@

Unit Hub

New session } +
Get notified

+ + Get notified + @@ -835,6 +851,70 @@

You're up to date

Upcoming

{{ sessions.length }} + @if (upNext; as next) { + @let nextWhen = timing(next); + + }

@@ -857,7 +937,12 @@

session of group.sessions; track session.occurrence_id || session.id + session.start_at ) { -
+ @let when = timing(session); +
@if (session.cancelled) {

Cancelled: this session will @@ -866,13 +951,34 @@

}
{{ unitCode(session.unit_id) }} - @if (sessionLive(session)) { - On now + @switch (when.state) { + @case ('now') { + Happening now + } + @case ('soon') { + Starts in + {{ when.minutes }} min + } + @case ('today') { + Today + } }

- @if (session.cancelled) { - Cancelled: + @switch (when.state) { + @case ('cancelled') { + Cancelled: + } + @case ('now') { + Happening now: + } + @case ('soon') { + Starts in {{ when.minutes }} min: + } } - @if (!session.cancelled && joinUrl(session)) { + @if (!session.cancelled && when.state !== 'now' && joinUrl(session)) { { expect(cancelled.querySelector('a[href^="https://calendar.google.com"]')).toBeNull(); expect(cards[0].querySelector('.cancelled-banner')).toBeNull(); }); + describe('deep links, Up next and notifications', () => { + it('opens an announcement named in the URL, marks it read and clears the parameter', async () => { + const id = feed().announcements[1].id; + const {component} = await open(`/unit-hub?unit=111&announcement=${id}`); + const dialogs = TestBed.inject(MatDialog); + await vi.waitFor(() => expect(dialogs.openDialogs).toHaveLength(1)); + const details = dialogs.openDialogs[0].componentInstance as UnitHubDetailsComponent; + expect(details.content.announcement.id).toBe(id); + expect(component.isUnread(component.announcements.find((row) => row.id === id))).toBe(false); + const url = TestBed.inject(Router).url; + expect(url).not.toContain('announcement='); + expect(url).toContain('unit=111'); + // clearing the parameter does not reload the feed or close the dialog + expect(service.feed).toHaveBeenCalledTimes(1); + expect(dialogs.openDialogs).toHaveLength(1); + }); + + it('opens a session named in the URL', async () => { + const id = feed().sessions[0].id; + await open(`/unit-hub?session=${id}`); + const dialogs = TestBed.inject(MatDialog); + await vi.waitFor(() => expect(dialogs.openDialogs).toHaveLength(1)); + const details = dialogs.openDialogs[0].componentInstance as UnitHubDetailsComponent; + expect(details.content.session.id).toBe(id); + expect(TestBed.inject(Router).url).not.toContain('session='); + }); + + it('explains when a linked update is no longer in the feed', async () => { + const snack = vi.spyOn(TestBed.inject(MatSnackBar), 'open'); + await open('/unit-hub?announcement=987654'); + await vi.waitFor(() => + expect(snack).toHaveBeenCalledWith( + 'That update is no longer available', + expect.anything(), + expect.anything(), + ), + ); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + expect(TestBed.inject(Router).url).not.toContain('announcement='); + }); + + it('pins the next active session at the top of Upcoming and highlights starting soon', async () => { + const data = feed(); + const base = data.sessions[0]; + const inMinutes = (minutes: number) => new Date(Date.now() + minutes * 60000).toISOString(); + data.sessions = [ + { + ...base, + id: 801, + title: 'Cancelled first', + cancelled: true, + start_at: inMinutes(10), + end_at: inMinutes(70), + }, + {...base, id: 802, title: 'Soon session', start_at: inMinutes(30), end_at: inMinutes(90)}, + ]; + service.feed.mockReturnValue(of(data)); + const {component, element} = await open(); + expect(component.upNext.id).toBe(802); + const upNext = element.querySelector('.up-next'); + expect(upNext.querySelector('.details-title')?.textContent.trim()).toBe('Soon session'); + expect(upNext.textContent).toMatch(/Starts in (29|30) min/); + const soonCard = Array.from(element.querySelectorAll('.session-card')).find((card) => + card.textContent.includes('Soon session'), + ); + expect(soonCard.getAttribute('data-timing')).toBe('soon'); + expect(soonCard.querySelector('h4 .visually-hidden')?.textContent).toContain('Starts in'); + const cancelledCard = Array.from(element.querySelectorAll('.session-card')).find((card) => + card.textContent.includes('Cancelled first'), + ); + expect(cancelledCard.getAttribute('data-timing')).toBe('cancelled'); + expect(cancelledCard.querySelector('.chip-soon, .chip-now, .chip-today')).toBeNull(); + }); + + it('links to the notification settings from the header', async () => { + const {element} = await open(); + const link = element.querySelector('a[aria-label^="Get notified"]') as HTMLAnchorElement; + expect(link.textContent).toContain('Get notified'); + expect(link.getAttribute('href')).toBe('/edit_profile#notification-settings-title'); + }); + }); }); diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index bffc9c004a..c2b6f360ae 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -1,5 +1,5 @@ import {CommonModule} from '@angular/common'; -import {ChangeDetectionStrategy, Component, OnDestroy, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit, inject} from '@angular/core'; import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatDialog, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; @@ -7,13 +7,15 @@ import {MatFormFieldModule} from '@angular/material/form-field'; import {MatIconModule} from '@angular/material/icon'; import {MatMenuModule} from '@angular/material/menu'; import {MatSelectModule} from '@angular/material/select'; -import {ActivatedRoute, NavigationStart, Router, RouterLink} from '@angular/router'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {ActivatedRoute, NavigationStart, ParamMap, Router, RouterLink} from '@angular/router'; import {Subscription, combineLatest, forkJoin} from 'rxjs'; import {CalendarModalService} from 'src/app/common/modals/calendar-modal/calendar-modal.service'; import {DemoModeStore} from 'src/app/demo/demo-mode.store'; import {StudyEssentialsComponent} from '../study-essentials/study-essentials.component'; import {AnnouncementReadStore} from './announcement-read.store'; import {HubMarkdownPipe, HubPlainTextPipe} from './hub-markdown'; +import {SessionTiming, sessionTiming, upNextSession} from './session-timing'; import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; import {TeamsMeetingDraft} from './teams-meeting-draft'; import { @@ -92,6 +94,14 @@ export class UnitHubComponent implements OnInit, OnDestroy { private staffRequest?: Subscription; private mutationRequest?: Subscription; private detailsRef?: MatDialogRef; + /** Refreshed every minute, so the time highlights and countdowns stay current. */ + now = Date.now(); + private clock?: ReturnType; + private readonly zone = inject(NgZone); + private readonly snackBar = inject(MatSnackBar); + /** An announcement or session named in the URL, opened once the feed has loaded. */ + private pendingLink: {announcement: number; session: number} | null = null; + private feedKey: string | null = null; readonly announcementForm; readonly sessionForm; @@ -140,17 +150,36 @@ export class UnitHubComponent implements OnInit, OnDestroy { }), ); this.subscriptions.add( - combineLatest([this.demo.enabled$, this.route.queryParamMap]).subscribe(([, params]) => { - this.mutationRequest?.unsubscribe(); - this.saving = false; - this.managing = false; - this.selectedUnitId = Number(params.get('unit')) || 0; - this.reload(); - }), + combineLatest([this.demo.enabled$, this.route.queryParamMap]).subscribe( + ([enabled, params]) => { + const announcement = Number(params.get('announcement')) || 0; + const session = Number(params.get('session')) || 0; + if (announcement || session) { + this.pendingLink = {announcement, session}; + } + // Clearing a deep link from the URL must not reload the feed or close its dialog. + const key = this.feedParamsKey(enabled, params); + if (key === this.feedKey && !this.loading && !this.loadError) { + this.openPendingLink(); + return; + } + this.feedKey = key; + this.mutationRequest?.unsubscribe(); + this.saving = false; + this.managing = false; + this.selectedUnitId = Number(params.get('unit')) || 0; + this.reload(); + }, + ), ); + // outside Angular, so a pending timer never holds the zone open; it re-enters to update + this.zone.runOutsideAngular(() => { + this.clock = setInterval(() => this.zone.run(() => (this.now = Date.now())), 60_000); + }); } ngOnDestroy(): void { + clearInterval(this.clock); this.closeDetails(); this.subscriptions.unsubscribe(); this.feedRequest?.unsubscribe(); @@ -260,11 +289,11 @@ export class UnitHubComponent implements OnInit, OnDestroy { this.editSession(); } } - sessionLive(session: LearningSession): boolean { - const now = Date.now(); - return ( - !session.cancelled && Date.parse(session.start_at) <= now && Date.parse(session.end_at) > now - ); + timing(session: LearningSession): SessionTiming { + return sessionTiming(session, this.now); + } + get upNext(): LearningSession | null { + return upNextSession(this.sessions, this.now); } reload(): void { @@ -288,8 +317,10 @@ export class UnitHubComponent implements OnInit, OnDestroy { if (this.managing) { this.loadStaff(this.managedUnitId); } + this.openPendingLink(); }, error: () => { + this.pendingLink = null; this.loadError = 'We could not load your unit updates. Please try again. If this continues, contact your teaching team.'; this.loading = false; @@ -305,6 +336,54 @@ export class UnitHubComponent implements OnInit, OnDestroy { }); } + private feedParamsKey(enabled: boolean, params: ParamMap): string { + const kept = params.keys + .filter((name) => name !== 'announcement' && name !== 'session') + .sort() + .map((name) => [name, params.getAll(name)]); + return JSON.stringify([enabled, kept]); + } + + /** + * Opens the announcement or session named by ?announcement= or ?session= once, then removes + * those parameters with replaceUrl so a refresh does not open it again. + */ + private openPendingLink(): void { + const link = this.pendingLink; + if (!link || this.loading || this.loadError) { + return; + } + this.pendingLink = null; + // after the current navigation settles; the URL change runs first because a router + // navigation closes any open details + setTimeout(() => { + void this.router + .navigate([], { + relativeTo: this.route, + queryParams: {announcement: null, session: null}, + queryParamsHandling: 'merge', + replaceUrl: true, + }) + .finally(() => { + const announcement = link.announcement + ? this.announcements.find((row) => row.id === link.announcement) + : undefined; + const session = + !announcement && link.session + ? (this.sessions.find((row) => row.id === link.session && !row.cancelled) ?? + this.sessions.find((row) => row.id === link.session)) + : undefined; + if (announcement) { + this.openAnnouncement(announcement); + } else if (session) { + this.openSession(session); + } else { + this.snackBar.open('That update is no longer available', 'Dismiss', {duration: 6000}); + } + }); + }); + } + openCalendar(): void { if (!this.demo.enabled) { this.calendars.show(); From d640ee3b5aa6b3c0b441890fc0a624bbc9009873 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:00:36 +1000 Subject: [PATCH 28/88] feat(about): restyle the About dialog with contributor cards and grouped sections A compact header with the logo, name and a one-line description. Lead contributors show as equal-height cards in a 3, 2 or 1 column grid with named avatars, text-colour names that wrap cleanly and a labelled profile link. The contributor table and licence notes sit under their own section labels, and Close is an outlined button in a right-aligned footer. Cards rise in with a short stagger that is skipped under reduced motion. --- ...out-doubtfire-modal-content.component.html | 244 ++++++----- ...-doubtfire-modal-content.component.spec.ts | 77 ++++ .../about-doubtfire-modal.component.ts | 1 + .../about-doubtfire-modal.scss | 390 ++++++++++++++++++ 4 files changed, 619 insertions(+), 93 deletions(-) create mode 100644 src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.spec.ts diff --git a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html index 5409fa2312..3a9ae7da93 100644 --- a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html +++ b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html @@ -1,101 +1,159 @@ - -
- OnTrack logo -

+
+ +
+

{{ data.externalName }} - - Version 6 - -

+ @if (data.externalName === 'Doubtfire') { + Version 6 + } +

+

Task-based learning, feedback and portfolio assessment.

- -
-

Lead Contributors

-
+
+

+ + Contributors +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Contributor + + + + + Contributions{{ element.totalContributions }}API{{ element.apiContributions }}Web{{ element.webContributions }}Deploy{{ element.deployContributions }}doubtfire.io{{ element.ioContributions }}
+ +
+
+
+ +
+

+ + Open source +

+
+

+ {{ data.externalName }} is licensed under the GNU Affero General Public License (AGPL) v3.0. + © 2012–2023. Made in Melbourne. +

+ +
+
- - + + + diff --git a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.spec.ts b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.spec.ts new file mode 100644 index 0000000000..3b88740511 --- /dev/null +++ b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.spec.ts @@ -0,0 +1,77 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {AboutDialogData} from './about-dialog-data'; +import {AboutDoubtfireModalContent} from './about-doubtfire-modal.component'; + +describe('AboutDoubtfireModalContent', () => { + let fixture: ComponentFixture; + let data: AboutDialogData; + + beforeEach(async () => { + data = new AboutDialogData(); + data.externalName = 'OnTrack'; + data.mainContributors = [ + { + login: 'macite', + name: 'Andrew Cain', + avatar_url: 'https://example.test/macite.png', + html_url: 'https://github.com/macite', + }, + { + login: 'alexcu', + name: 'Alex Cummaudo', + avatar_url: 'https://example.test/alexcu.png', + html_url: 'https://github.com/alexcu', + }, + { + login: 'jakerenzella', + name: undefined, + avatar_url: '/assets/images/person-unknown.gif', + html_url: undefined, + }, + ]; + + await TestBed.configureTestingModule({ + declarations: [AboutDoubtfireModalContent], + providers: [{provide: MAT_DIALOG_DATA, useValue: data}], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(AboutDoubtfireModalContent); + fixture.detectChanges(); + }); + + const cards = () => + Array.from(fixture.nativeElement.querySelectorAll('.about__card')) as HTMLElement[]; + + it('renders a card with a name and a named avatar for each lead contributor', () => { + const rendered = cards(); + expect(rendered).toHaveLength(3); + + const expected = ['Andrew Cain', 'Alex Cummaudo', 'jakerenzella']; + rendered.forEach((card, i) => { + const name = card.querySelector('.about__person-name') as HTMLElement; + const img = card.querySelector('img') as HTMLImageElement; + expect(name.textContent.trim()).toBe(expected[i]); + expect(img.getAttribute('alt')).toBe(expected[i]); + }); + }); + + it('labels profile links and omits them until a profile URL is known', () => { + const [andrew, , jake] = cards(); + const link = andrew.querySelector('a.about__icon-link') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('https://github.com/macite'); + expect(link.getAttribute('aria-label')).toContain('Andrew Cain on GitHub'); + expect(jake.querySelector('a.about__icon-link')).toBeNull(); + }); + + it('keeps a close action in the dialog footer', () => { + const close = fixture.nativeElement.querySelector( + 'mat-dialog-actions button', + ) as HTMLButtonElement; + expect(close.textContent.trim()).toBe('Close'); + expect(close.hasAttribute('mat-dialog-close')).toBe(true); + }); +}); diff --git a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts index 123accbbb9..e50cbb4d79 100644 --- a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts +++ b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts @@ -12,6 +12,7 @@ import {GithubProfile} from './github-profile'; @Component({ selector: 'about-doubtfire-dialog', templateUrl: 'about-doubtfire-modal-content.component.html', + styleUrls: ['about-doubtfire-modal.scss'], changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) diff --git a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.scss b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.scss index e69de29bb2..d70ac1979e 100644 --- a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.scss +++ b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.scss @@ -0,0 +1,390 @@ +// About dialog. Theme tokens only, so light and dark both follow the resolved theme. +// Spacing sits on a 24px dialog gutter with 16px and 8px steps inside it. + +$ease-out: cubic-bezier(0.23, 1, 0.32, 1); +$gutter: 24px; + +:host { + // Material renders the dialog host as display: contents, so it is left alone and + // only carries inherited values. + color: var(--ot-color-text); + + // The dark border token sits just above the surface, so it reads as an edge. In + // light the same token is a strong grey, so the card edge is softened towards it. + --about-card-border: color-mix(in srgb, var(--ot-color-border) 45%, var(--ot-color-surface)); +} + +:host-context([data-ot-theme='dark']) { + --about-card-border: var(--ot-color-border); +} + +// --- Header --------------------------------------------------------------- + +.about__header { + display: flex; + align-items: center; + gap: 16px; + padding: $gutter $gutter 16px; +} + +.about__logo { + flex: none; + width: 40px; + height: 40px; +} + +.about__identity { + min-width: 0; +} + +// Material's dialog title brings its own padding and a 40px baseline strut. The +// header owns the spacing here, so both are cleared. +.about__name.mat-mdc-dialog-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + margin: 0; + padding: 0; + font-size: 1.375rem; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.01em; + color: var(--ot-color-text); + + &::before { + display: none; + } +} + +.about__version { + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: 0; + color: var(--ot-color-text-muted); +} + +.about__tagline { + margin: 2px 0 0; + font-size: 0.875rem; + line-height: 1.4; + color: var(--ot-color-text-muted); +} + +// --- Body ----------------------------------------------------------------- + +.about__content.mat-mdc-dialog-content { + display: flex; + flex-direction: column; + gap: $gutter; + padding: 8px $gutter $gutter; + color: var(--ot-color-text); + container-type: inline-size; +} + +.about__section { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.about__label { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.4; + color: var(--ot-color-text); +} + +.about__label-icon { + width: 18px; + height: 18px; + font-size: 18px; + color: var(--ot-color-text-muted); +} + +// --- Lead contributor cards ----------------------------------------------- + +.about__lead-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +@container (min-width: 440px) { + .about__lead-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container (min-width: 640px) { + .about__lead-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.about__card { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + min-width: 0; + padding: 20px 16px 16px; + border: 1px solid var(--about-card-border); + border-radius: var(--ot-radius-md); + background: var(--ot-color-surface); + text-align: center; + animation: about-card-in 200ms $ease-out both; + animation-delay: calc(var(--about-i, 0) * 50ms); +} + +@keyframes about-card-in { + from { + opacity: 0; + transform: translateY(8px); + } +} + +.about__avatar { + display: block; + width: 80px; + height: 80px; + border-radius: var(--ot-radius-circle); + object-fit: cover; + background: var(--ot-color-surface-raised); + box-shadow: + 0 0 0 3px var(--ot-color-surface), + 0 0 0 4px var(--about-card-border); + transition: transform 200ms $ease-out; +} + +@media (hover: hover) and (pointer: fine) { + .about__card:hover .about__avatar { + transform: translateY(-2px); + } +} + +.about__person { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + max-width: 100%; +} + +.about__person-name { + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + color: var(--ot-color-text); + overflow-wrap: anywhere; + text-wrap: balance; +} + +.about__person-login { + font-size: 0.8125rem; + line-height: 1.4; + color: var(--ot-color-text-muted); + overflow-wrap: anywhere; +} + +// A small outlined icon button, pinned to the card foot so buttons line up across +// cards of equal height. +.about__icon-link.mat-mdc-icon-button { + --mat-icon-button-state-layer-size: 36px; + --mat-icon-button-icon-size: 18px; + margin-top: auto; + width: 36px; + height: 36px; + padding: 8px; + border: 1px solid var(--about-card-border); + border-radius: var(--ot-radius-circle); + color: var(--ot-color-text-muted); + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } +} + +// One column: a compact row per person, so a phone does not stack three tall cards. +// The link sits under the name so the name keeps the full text column and wraps +// only at a space. +@container (max-width: 439px) { + .about__card { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: center; + column-gap: 16px; + row-gap: 8px; + padding: 16px; + text-align: start; + } + + .about__avatar { + grid-row: span 2; + width: 72px; + height: 72px; + } + + .about__person { + align-self: end; + } + + .about__person-name, + .about__person-login { + overflow-wrap: break-word; + } + + .about__icon-link.mat-mdc-icon-button { + align-self: start; + justify-self: start; + margin-top: 0; + } +} + +// --- Contributors table --------------------------------------------------- + +.about__table-frame { + overflow-x: auto; + border: 1px solid var(--about-card-border); + border-radius: var(--ot-radius-md); + background: var(--ot-color-surface); +} + +.about__table { + --mat-table-background-color: transparent; + width: 100%; + background: transparent; + + .mat-mdc-header-cell { + font-size: 0.8125rem; + font-weight: 600; + color: var(--ot-color-text-muted); + } + + .mat-mdc-cell { + color: var(--ot-color-text); + font-variant-numeric: tabular-nums; + } + + .mat-mdc-row:last-of-type .mat-mdc-cell { + border-bottom: 0; + } +} + +.about__table-person { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 0; + padding-block: 8px; + color: var(--ot-color-text); + text-decoration: none; + + &:hover .about__table-login { + color: var(--ot-color-link); + text-decoration: underline; + } +} + +.about__table-avatar { + flex: none; + width: 32px; + height: 32px; + border-radius: var(--ot-radius-circle); + background: var(--ot-color-surface-raised); +} + +.about__table-login { + overflow-wrap: anywhere; +} + +// Narrow dialogs keep the contributor and total, and drop the per-repository split, +// so the table never scrolls sideways on a phone. +@container (max-width: 559px) { + // Sub-pixel column widths can leave a 1px overflow that would show a scrollbar. + .about__table-frame { + overflow-x: clip; + } + + .about__table { + .mat-column-api-contributions, + .mat-column-web-contributions, + .mat-column-deploy-contributions, + .mat-column-io-contributions { + display: none; + } + + .mat-mdc-header-cell, + .mat-mdc-cell { + padding-inline: 12px 8px; + } + + .about__table-person { + gap: 8px; + } + } +} + +// --- Open source ---------------------------------------------------------- + +.about__open-source { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 16px; +} + +.about__note { + flex: 1 1 260px; + margin: 0; + font-size: 0.875rem; + line-height: 1.5; + color: var(--ot-color-text); +} + +.about__note-muted { + display: block; + color: var(--ot-color-text-muted); +} + +.about__source-links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +// --- Footer --------------------------------------------------------------- + +.about__actions.mat-mdc-dialog-actions { + gap: 8px; + min-height: 0; + padding: 8px $gutter $gutter; +} + +// --- Reduced motion ------------------------------------------------------- + +@media (prefers-reduced-motion: reduce) { + .about__card { + animation: none; + } + + .about__avatar { + transition: none; + } + + .about__card:hover .about__avatar { + transform: none; + } +} From bf79fd861767bd25f8068f1318bc1fb9b339f1b9 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:06:05 +1000 Subject: [PATCH 29/88] feat(startup): redraw the splash, progress line and skeleton shimmer Draw the OnTrack mark stroke by stroke with a calm sheen loop, fade the wordmark up after it and crossfade the status text. Exit faster than it enters. A pre-boot loader in index.html matches the splash so the hand-off does not jump. Data loading shows a 2px indeterminate line with a caption after 1.5s. Skeletons share one sheen phase so they move together. Reduced motion keeps everything static. --- .../splash-screen.component.html | 45 ++- .../splash-screen.component.scss | 380 ++++++++++++++---- .../splash-screen.component.spec.ts | 51 +++ src/index.html | 186 ++++++++- src/styles.scss | 57 ++- 5 files changed, 637 insertions(+), 82 deletions(-) diff --git a/src/app/home/splash-screen/splash-screen.component.html b/src/app/home/splash-screen/splash-screen.component.html index c8357a173a..79ca0e76ff 100644 --- a/src/app/home/splash-screen/splash-screen.component.html +++ b/src/app/home/splash-screen/splash-screen.component.html @@ -1,6 +1,7 @@ @if (startupState$ | async; as state) { @if (state.status === 'loading' && state.phase === 'authentication') {
-
} @else if (state.status === 'loading') { @@ -23,8 +52,10 @@ data-testid="startup-data-loading" role="status" > - - {{ state.message }} + + @if (state.message) { + {{ state.message }} + } } @else if (state.status === 'error' || state.status === 'offline') {
{ ).toBeTruthy(); }); + it('draws the mark, the wordmark and the current status while signing in', () => { + const splash = fixture.nativeElement.querySelector('[data-testid="startup-auth-loading"]'); + const mark = splash.querySelector('[data-testid="startup-mark"]'); + + expect(splash.getAttribute('role')).toBe('status'); + expect(splash.getAttribute('aria-live')).toBe('polite'); + expect(mark.querySelectorAll('svg path')).toHaveLength(3); + expect(mark.getAttribute('aria-hidden')).toBe('true'); + expect(splash.querySelector('[data-testid="startup-wordmark"]').textContent.trim()).toBe( + 'OnTrack', + ); + expect(splash.querySelector('[data-testid="startup-status"]').textContent.trim()).toBe( + 'Checking your session…', + ); + expect(splash.querySelector('img')).toBeFalsy(); + }); + + it('swaps the status text in place when the message changes', () => { + startupState.next({ + status: 'loading', + phase: 'authentication', + message: 'Still checking your session…', + attempt: 2, + startedAt: Date.now(), + }); + fixture.detectChanges(); + + const statuses = fixture.nativeElement.querySelectorAll('[data-testid="startup-status"]'); + expect(statuses).toHaveLength(1); + expect(statuses[0].textContent.trim()).toBe('Still checking your session…'); + }); + + it('shows a thin progress line with the message as a caption while data loads', () => { + startupState.next({ + status: 'loading', + phase: 'units-and-projects', + message: 'Loading your units…', + attempt: 1, + startedAt: Date.now(), + }); + fixture.detectChanges(); + + const progress = fixture.nativeElement.querySelector('[data-testid="startup-data-loading"]'); + expect(progress.getAttribute('role')).toBe('status'); + expect(progress.getAttribute('aria-live')).toBe('polite'); + expect(progress.querySelector('.startup-progress-track span')).toBeTruthy(); + expect(progress.querySelector('.startup-progress-caption').textContent.trim()).toBe( + 'Loading your units…', + ); + }); + it('renders a terminal recovery action instead of an indefinite logo', () => { startupState.next({ status: 'error', diff --git a/src/index.html b/src/index.html index 57b5ed55ee..833aaabd11 100644 --- a/src/index.html +++ b/src/index.html @@ -27,6 +27,148 @@ document.documentElement.style.colorScheme = resolved; + + + - + + + + diff --git a/src/styles.scss b/src/styles.scss index cca9d65450..8033ce6399 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -226,12 +226,65 @@ body.split-pane-resizing pdf-viewer { // element, in its encapsulated styles, so every loading placeholder stayed light // in dark mode. Restate them there with the divider token the hand-built // skeletons use. The :root prefix outweighs the library's attribute selector. +// +// The library also runs a separate 200px shimmer per placeholder, each on its +// own clock, so a page of skeletons flickered out of step. Instead one shared +// phase animates on the root, only while a skeleton is on the page, and every +// placeholder moves a soft diagonal sheen by it. They all cross together. +@property --ot-skeleton-sheen { + syntax: ''; + inherits: true; + initial-value: -100%; +} + +@keyframes ot-skeleton-sheen { + from { + --ot-skeleton-sheen: -100%; + } + to { + --ot-skeleton-sheen: 100%; + } +} + +:root:has(ngx-skeleton-loader .skeleton-loader.progress) { + animation: ot-skeleton-sheen 1.6s linear infinite; +} + :root ngx-skeleton-loader .skeleton-loader { --ngx-skeleton-loader-base-color: var(--ot-color-divider); + --ot-skeleton-sheen-color: color-mix(in srgb, var(--ot-color-divider), #fff 14%); +} + +@supports (color: oklch(from red l c h)) { + :root ngx-skeleton-loader .skeleton-loader { + --ot-skeleton-sheen-color: oklch(from var(--ot-color-divider) calc(l + 0.06) c h); + } +} + +:root ngx-skeleton-loader .skeleton-loader.progress::before { + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: linear-gradient( + 105deg, + transparent 25%, + var(--ot-skeleton-sheen-color) 50%, + transparent 75% + ); + background-size: 100% 100%; + transform: translateX(var(--ot-skeleton-sheen)); + animation: none; } -:root[data-ot-theme='dark'] ngx-skeleton-loader .skeleton-loader { - --ngx-skeleton-loader-light-mode-color: rgba(255, 255, 255, 0.08); +@media (prefers-reduced-motion: reduce) { + :root:has(ngx-skeleton-loader .skeleton-loader.progress) { + animation: none; + } + + :root ngx-skeleton-loader .skeleton-loader.progress::before { + display: none; + } } // Gantt (ngx-gantt) dark theme. The library paints from var(--gantt-color-*, From da2da756c97005e3be57f7d2bb07f877d516c7a6 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:07:31 +1000 Subject: [PATCH 30/88] feat(burndown): add crosshair, shared tooltip and series emphasis on hover Snap a crosshair to the nearest date with a dot per series and a shared tooltip showing each value and the gap against Target. Hovering a line, legend chip or summary tile emphasises that series. Works with touch drag, arrow keys and a polite live region, and respects reduced motion. --- .../burndown-hover.spec.ts | 159 ++++++ .../progress-burndown-chart/burndown-hover.ts | 229 ++++++++ .../progress-burndown-chart.component.html | 78 ++- .../progress-burndown-chart.component.scss | 202 ++++++++ .../progress-burndown-chart.component.ts | 488 +++++++++++++++++- .../progress-burndown-chart.hover.spec.ts | 184 +++++++ 6 files changed, 1329 insertions(+), 11 deletions(-) create mode 100644 src/app/visualisations/progress-burndown-chart/burndown-hover.spec.ts create mode 100644 src/app/visualisations/progress-burndown-chart/burndown-hover.ts create mode 100644 src/app/visualisations/progress-burndown-chart/progress-burndown-chart.hover.spec.ts diff --git a/src/app/visualisations/progress-burndown-chart/burndown-hover.spec.ts b/src/app/visualisations/progress-burndown-chart/burndown-hover.spec.ts new file mode 100644 index 0000000000..e148de1609 --- /dev/null +++ b/src/app/visualisations/progress-burndown-chart/burndown-hover.spec.ts @@ -0,0 +1,159 @@ +import {describe, expect, it} from 'vitest'; +import { + BurndownSeries, + buildTooltip, + burndownDates, + describeTooltip, + formatGap, + nearestIndex, + nearestSeriesAt, + stepIndex, + targetValueAt, +} from './burndown-hover'; + +const DATA: BurndownSeries[] = [ + { + name: 'Target', + series: [ + {name: '1 Jul', value: 100}, + {name: '8 Jul', value: 60}, + {name: '15 Jul', value: 0}, + ], + }, + { + name: 'Projected', + series: [ + {name: '1 Jul', value: 100}, + {name: '8 Jul', value: 64}, + {name: '15 Jul', value: 30}, + {name: '22 Jul', value: 0}, + ], + }, + { + name: 'To Submit', + series: [ + {name: '1 Jul', value: 90}, + {name: '8 Jul', value: 57}, + ], + }, +]; +const COLORS = ['grey-1', 'grey-2', 'blue']; + +describe('burndown hover helpers', () => { + describe('nearest-date snapping', () => { + const positions = [0, 50, 100, 150]; + + it('snaps to the closest date position', () => { + expect(nearestIndex(positions, 0)).toBe(0); + expect(nearestIndex(positions, 24)).toBe(0); + expect(nearestIndex(positions, 26)).toBe(1); + expect(nearestIndex(positions, 149)).toBe(3); + }); + + it('clamps positions outside the plot to the first and last dates', () => { + expect(nearestIndex(positions, -40)).toBe(0); + expect(nearestIndex(positions, 400)).toBe(3); + }); + + it('prefers the earlier date on an exact midpoint and returns -1 with no dates', () => { + expect(nearestIndex(positions, 75)).toBe(1); + expect(nearestIndex([], 10)).toBe(-1); + }); + + it('builds the date domain in first-seen order like ngx-charts', () => { + expect(burndownDates(DATA)).toEqual(['1 Jul', '8 Jul', '15 Jul', '22 Jul']); + }); + }); + + describe('tooltip rows', () => { + it('lists each series with a value at the date, with its colour and a percent label', () => { + const tooltip = buildTooltip(DATA, COLORS, burndownDates(DATA), 1, DATA[0]); + + expect(tooltip?.date).toBe('8 Jul'); + expect(tooltip?.rows.map((row) => [row.name, row.color, row.valueLabel])).toEqual([ + ['Target', 'grey-1', '60%'], + ['Projected', 'grey-2', '64%'], + ['To Submit', 'blue', '57%'], + ]); + }); + + it('leaves out series that have ended before the date', () => { + const tooltip = buildTooltip(DATA, COLORS, burndownDates(DATA), 2, DATA[0]); + + expect(tooltip?.rows.map((row) => row.name)).toEqual(['Target', 'Projected']); + }); + + it('returns null for a date that does not exist', () => { + expect(buildTooltip(DATA, COLORS, burndownDates(DATA), 9, DATA[0])).toBeNull(); + }); + + it('mirrors the tooltip as a sentence for the live region', () => { + const tooltip = buildTooltip(DATA, COLORS, burndownDates(DATA), 1, DATA[0]); + + expect(describeTooltip(tooltip!)).toBe( + '8 Jul. Target 60%. Projected 64%, +4% behind. To Submit 57%, 3% ahead.', + ); + }); + }); + + describe('ahead and behind', () => { + it('labels work above Target as behind and below as ahead', () => { + expect(formatGap(4)).toBe('+4% behind'); + expect(formatGap(-3)).toBe('3% ahead'); + expect(formatGap(0)).toBe('On target'); + }); + + it('derives the gap from Target at the same date, and never on the Target row', () => { + const rows = buildTooltip(DATA, COLORS, burndownDates(DATA), 1, DATA[0])!.rows; + + expect(rows[0].gap).toBeUndefined(); + expect(rows[1].gap).toEqual({delta: 4, label: '+4% behind'}); + expect(rows[2].gap).toEqual({delta: -3, label: '3% ahead'}); + }); + + it('treats a date after Target reaches zero as a zero target', () => { + const dates = burndownDates(DATA); + + expect(targetValueAt(DATA[0], dates, '22 Jul')).toBe(0); + expect(buildTooltip(DATA, COLORS, dates, 3, DATA[0])!.rows[0].gap?.label).toBe('On target'); + }); + + it('omits the gap when there is no Target series', () => { + const rows = buildTooltip(DATA.slice(1), COLORS, burndownDates(DATA), 1)!.rows; + + expect(rows.every((row) => row.gap === undefined)).toBe(true); + }); + }); + + describe('keyboard stepping', () => { + it('starts from the first date going right and the last date going left', () => { + expect(stepIndex(null, 1, 4)).toBe(0); + expect(stepIndex(null, -1, 4)).toBe(3); + }); + + it('moves one date at a time and stops at either end', () => { + expect(stepIndex(1, 1, 4)).toBe(2); + expect(stepIndex(1, -1, 4)).toBe(0); + expect(stepIndex(0, -1, 4)).toBe(0); + expect(stepIndex(3, 1, 4)).toBe(3); + expect(stepIndex(2, 1, 0)).toBeNull(); + }); + }); + + describe('line hit testing', () => { + const positions = [0, 100]; + const ys = [ + [0, 100], + [100, 100], + ]; + + it('finds the line under the pointer between two dates', () => { + expect(nearestSeriesAt(positions, ys, 50, 52, 8)).toBe(0); + expect(nearestSeriesAt(positions, ys, 50, 97, 8)).toBe(1); + }); + + it('ignores the pointer when no line is within the tolerance', () => { + expect(nearestSeriesAt(positions, ys, 50, 75, 8)).toBe(-1); + }); + }); +}); diff --git a/src/app/visualisations/progress-burndown-chart/burndown-hover.ts b/src/app/visualisations/progress-burndown-chart/burndown-hover.ts new file mode 100644 index 0000000000..9c5e206d50 --- /dev/null +++ b/src/app/visualisations/progress-burndown-chart/burndown-hover.ts @@ -0,0 +1,229 @@ +// Pure helpers behind the burndown chart's crosshair, tooltip and series emphasis. +// They take plain series data and pixel positions so they can be tested without +// rendering ngx-charts. + +export interface BurndownPoint { + name: string; + value: number; +} + +export interface BurndownSeries { + name: string; + series: BurndownPoint[]; +} + +export interface BurndownGap { + /** Work remaining minus Target, in percentage points. Positive is behind. */ + delta: number; + label: string; +} + +export interface BurndownTooltipRow { + name: string; + color: string; + value: number; + valueLabel: string; + gap?: BurndownGap; +} + +export interface BurndownTooltip { + date: string; + rows: BurndownTooltipRow[]; +} + +export const TARGET_SERIES = 'Target'; + +/** + * The x domain in the order ngx-charts builds it: every distinct point name, first + * seen first, across the series on show. + */ +export function burndownDates(data: BurndownSeries[]): string[] { + const seen: Set = new Set(); + const dates: string[] = []; + + for (const series of data) { + for (const point of series.series) { + if (!seen.has(point.name)) { + seen.add(point.name); + dates.push(point.name); + } + } + } + + return dates; +} + +/** Index of the position closest to x. Ties go to the earlier date. -1 when empty. */ +export function nearestIndex(positions: number[], x: number): number { + let best = -1; + let bestDistance = Infinity; + + positions.forEach((position, index) => { + const distance = Math.abs(position - x); + + if (distance < bestDistance) { + best = index; + bestDistance = distance; + } + }); + + return best; +} + +/** Moves an index by one step, starting from the nearest end when nothing is shown. */ +export function stepIndex(current: number | null, delta: number, count: number): number | null { + if (count <= 0) { + return null; + } + + if (current === null || current < 0 || current >= count) { + return delta < 0 ? count - 1 : 0; + } + + return Math.min(count - 1, Math.max(0, current + delta)); +} + +export function formatGap(delta: number): string { + if (delta > 0) { + return `+${delta}% behind`; + } + + if (delta < 0) { + return `${-delta}% ahead`; + } + + return 'On target'; +} + +/** + * Target work remaining at a date. Target stops once it reaches zero, so a date past + * its last point reads as that last value. + */ +export function targetValueAt( + target: BurndownSeries | undefined, + dates: string[], + date: string, +): number | undefined { + if (!target || target.series.length === 0) { + return undefined; + } + + const point = target.series.find((entry) => entry.name === date); + + if (point) { + return point.value; + } + + const last = target.series[target.series.length - 1]; + const lastIndex = dates.indexOf(last.name); + const dateIndex = dates.indexOf(date); + + return lastIndex >= 0 && dateIndex > lastIndex ? last.value : undefined; +} + +/** One row per series on show that has a value at the date, in chart order. */ +export function buildTooltip( + data: BurndownSeries[], + colors: string[], + dates: string[], + index: number, + target?: BurndownSeries, +): BurndownTooltip | null { + const date = dates[index]; + + if (date === undefined) { + return null; + } + + const targetValue = targetValueAt(target, dates, date); + const rows: BurndownTooltipRow[] = []; + + data.forEach((series, seriesIndex) => { + const point = series.series.find((entry) => entry.name === date); + + if (!point) { + return; + } + + const row: BurndownTooltipRow = { + name: series.name, + color: colors[seriesIndex], + value: point.value, + valueLabel: `${point.value}%`, + }; + + if (series.name !== TARGET_SERIES && targetValue !== undefined) { + const delta = point.value - targetValue; + row.gap = {delta, label: formatGap(delta)}; + } + + rows.push(row); + }); + + return {date, rows}; +} + +/** Screen reader copy for the same tooltip. */ +export function describeTooltip(tooltip: BurndownTooltip): string { + const rows = tooltip.rows.map((row) => + row.gap ? `${row.name} ${row.valueLabel}, ${row.gap.label}` : `${row.name} ${row.valueLabel}`, + ); + + return [tooltip.date, ...rows].join('. ') + '.'; +} + +/** + * The series whose line passes closest to (x, y), if any is within the tolerance. + * `ys[s][d]` is series s at date d in pixels, or undefined where it has no point. + * Lines are straight between points, matching the chart's linear curve. + */ +export function nearestSeriesAt( + positions: number[], + ys: (number | undefined)[][], + x: number, + y: number, + tolerance: number, +): number { + if (positions.length === 0) { + return -1; + } + + let right = positions.findIndex((position) => position >= x); + + if (right === -1) { + right = positions.length - 1; + } + + const left = Math.max(0, positions[right] === x ? right : right - 1); + let best = -1; + let bestDistance = tolerance; + + ys.forEach((values, seriesIndex) => { + const a = values[left]; + const b = values[right]; + let lineY: number | undefined; + + if (a !== undefined && b !== undefined && positions[right] !== positions[left]) { + const t = (x - positions[left]) / (positions[right] - positions[left]); + lineY = a + (b - a) * Math.min(1, Math.max(0, t)); + } else if (a !== undefined && Math.abs(x - positions[left]) <= tolerance) { + // The line ends at this point, so only its end cap is hoverable. + lineY = a; + } else if (b !== undefined && Math.abs(positions[right] - x) <= tolerance) { + lineY = b; + } + + if (lineY === undefined) { + return; + } + + const distance = Math.abs(lineY - y); + + if (distance <= bestDistance) { + best = seriesIndex; + bestDistance = distance; + } + }); + + return best; +} diff --git a/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html index 73022ea0b5..0fa410defd 100644 --- a/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html +++ b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html @@ -1,7 +1,16 @@ -
-
+
+
+ + + + @for (dot of dots; track dot.name) { + + } + + + +

{{ liveText }}

@if (summaries.length > 0) {
@for (summary of summaries; track summary.name) { -
+
+ +
+ + Unit Hub updates + + + New and changed announcements, and sessions that move or are cancelled. Shown here in OnTrack. + Choose below if you also want them sent to you. + + +
+ + Email + + + Push + + + Session reminders + +
+ + Push only reaches browsers where push notifications are turned on. Session reminders arrive 30 + minutes before a session starts. + +
diff --git a/src/app/common/notification-settings/notification-settings.component.scss b/src/app/common/notification-settings/notification-settings.component.scss index 1957141191..cd80dd827f 100644 --- a/src/app/common/notification-settings/notification-settings.component.scss +++ b/src/app/common/notification-settings/notification-settings.component.scss @@ -36,6 +36,17 @@ width: 100%; } +.notification-setting__channels { + display: flex; + flex-wrap: wrap; + gap: 0 16px; + padding-inline-start: 32px; +} + +.notification-setting__channels mat-checkbox { + width: auto; +} + .notification-setting__help { color: var(--ot-color-text-muted); line-height: 1.4; diff --git a/src/app/common/notification-settings/notification-settings.component.spec.ts b/src/app/common/notification-settings/notification-settings.component.spec.ts index 64d67af027..c805505189 100644 --- a/src/app/common/notification-settings/notification-settings.component.spec.ts +++ b/src/app/common/notification-settings/notification-settings.component.spec.ts @@ -15,6 +15,10 @@ const makeUser = (): User => receiveTaskNotifications: false, receiveFeedbackNotifications: false, receivePortfolioNotifications: false, + receiveUnitHubNotifications: true, + receiveUnitHubEmailNotifications: false, + receiveUnitHubPushNotifications: false, + receiveUnitHubSessionReminders: false, }) as User; describe('NotificationSettingsComponent', () => { @@ -39,15 +43,70 @@ describe('NotificationSettingsComponent', () => { expect(component).toBeTruthy(); }); - it('shows the three notification preferences', async () => { + it('shows the four notification categories and the Unit Hub channels', async () => { const loader = TestbedHarnessEnvironment.loader(fixture); const checkboxes = await loader.getAllHarnesses(MatCheckboxHarness); - expect(checkboxes.length).toBe(3); + expect(await Promise.all(checkboxes.map((checkbox) => checkbox.getLabelText()))).toEqual([ + 'Task notifications', + 'Feedback notifications', + 'Portfolio notifications', + 'Unit Hub updates', + 'Email', + 'Push', + 'Session reminders', + ]); + }); + + it('renders the Unit Hub row like the other categories', () => { + const rows: HTMLElement[] = Array.from( + fixture.nativeElement.querySelectorAll('.notification-setting'), + ); + const hubRow = rows[3]; - expect(await checkboxes[0].getLabelText()).toBe('Task notifications'); - expect(await checkboxes[1].getLabelText()).toBe('Feedback notifications'); - expect(await checkboxes[2].getLabelText()).toBe('Portfolio notifications'); + expect(rows.length).toBe(4); + expect(hubRow.querySelector('small')?.id).toBe('unit-hub-notification-description'); + expect(hubRow.textContent).toContain('New and changed announcements'); + expect(hubRow.querySelector('[role="group"]')?.getAttribute('aria-label')).toBe( + 'Also send Unit Hub updates by', + ); + }); + + it('starts Unit Hub updates in the app only, and saves each channel to its own field', async () => { + const loader = TestbedHarnessEnvironment.loader(fixture); + const hub = await loader.getHarness(MatCheckboxHarness.with({label: 'Unit Hub updates'})); + const email = await loader.getHarness(MatCheckboxHarness.with({label: 'Email'})); + const push = await loader.getHarness(MatCheckboxHarness.with({label: 'Push'})); + const reminders = await loader.getHarness( + MatCheckboxHarness.with({label: 'Session reminders'}), + ); + + expect(await hub.isChecked()).toBe(true); + expect(await email.isChecked()).toBe(false); + expect(await push.isChecked()).toBe(false); + expect(await reminders.isChecked()).toBe(false); + + await email.check(); + await reminders.check(); + + expect(component.user.receiveUnitHubEmailNotifications).toBe(true); + expect(component.user.receiveUnitHubPushNotifications).toBe(false); + expect(component.user.receiveUnitHubSessionReminders).toBe(true); + }); + + it('turns the Unit Hub channels off while the category is off', async () => { + const loader = TestbedHarnessEnvironment.loader(fixture); + const hub = await loader.getHarness(MatCheckboxHarness.with({label: 'Unit Hub updates'})); + + await hub.uncheck(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.user.receiveUnitHubNotifications).toBe(false); + for (const label of ['Email', 'Push', 'Session reminders']) { + const channel = await loader.getHarness(MatCheckboxHarness.with({label})); + expect(await channel.isDisabled()).toBe(true); + } }); it('updates the correct user preference when toggled', async () => { @@ -85,19 +144,23 @@ describe('NotificationSettingsComponent', () => { expect(text).toContain('help requests and extension requests'); expect(text).toContain('New comments, feedback, and review outcomes.'); expect(text).toContain('Portfolio processing and assessment updates.'); + expect(text).toContain('Session reminders arrive 30 minutes before a session starts.'); }); it('associates each checkbox with its help text', () => { - const inputs = fixture.nativeElement.querySelectorAll('input[type="checkbox"]'); - const descriptions = fixture.nativeElement.querySelectorAll('.notification-setting small'); + const inputs = fixture.nativeElement.querySelectorAll( + '.notification-setting > mat-checkbox input[type="checkbox"]', + ); + const descriptions = fixture.nativeElement.querySelectorAll('.notification-setting small[id]'); const descriptionIds = [ 'task-notification-description', 'feedback-notification-description', 'portfolio-notification-description', + 'unit-hub-notification-description', ]; - expect(inputs.length).toBe(3); - expect(descriptions.length).toBe(3); + expect(inputs.length).toBe(4); + expect(descriptions.length).toBe(4); descriptionIds.forEach((id, index) => { expect(descriptions[index].id).toBe(id); From f1f46199ea3157f8f933cbf0340e39673f4cd95d Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:19:18 +1000 Subject: [PATCH 34/88] fix(unit-hub): open a weekly session's next occurrence from a notification link --- src/app/unit-hub/unit-hub.component.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index c2b6f360ae..b2c4575ebb 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -370,7 +370,14 @@ export class UnitHubComponent implements OnInit, OnDestroy { : undefined; const session = !announcement && link.session - ? (this.sessions.find((row) => row.id === link.session && !row.cancelled) ?? + ? // a weekly session repeats with the same id, so open its next occurrence + (this.sessions.find( + (row) => + row.id === link.session && + !row.cancelled && + Date.parse(row.end_at) >= Date.now(), + ) ?? + this.sessions.find((row) => row.id === link.session && !row.cancelled) ?? this.sessions.find((row) => row.id === link.session)) : undefined; if (announcement) { From 7d1e16fb1e319e2038b16d915e7f6631ac790e71 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:28:47 +1000 Subject: [PATCH 35/88] style(unit-hub): quiet whole-card state tints instead of side bars, cancelled recedes --- src/app/unit-hub/_hub-card.scss | 30 +++++++++++------- .../unit-hub/unit-hub-details.component.scss | 3 +- src/app/unit-hub/unit-hub.component.scss | 31 +++++++------------ 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/app/unit-hub/_hub-card.scss b/src/app/unit-hub/_hub-card.scss index 1c476255ad..6869179281 100644 --- a/src/app/unit-hub/_hub-card.scss +++ b/src/app/unit-hub/_hub-card.scss @@ -115,13 +115,21 @@ } } -// A cancelled item: sunken toward the page ground, an error-tinted edge and a 4px error -// accent bar on the left. No dashed edge and no elevation. +// A state tint over the whole card: a wash of the state colour on the surface and an +// edge in the same hue. Used instead of a side accent bar. +@mixin tinted-card($tone, $wash: 10%, $edge: 45%) { + border-style: solid; + border-color: color-mix(in srgb, #{$tone} #{$edge}, var(--ot-color-border)); + background: color-mix(in srgb, #{$tone} #{$wash}, var(--ot-color-surface)); +} + +// A cancelled item recedes: sunken toward the page, a plain edge, no elevation. The +// banner and struck title say what happened, so the card itself stays quiet. @mixin cancelled-card { border-style: solid; - border-color: color-mix(in srgb, var(--ot-color-error) 40%, var(--ot-color-border)); - background: color-mix(in srgb, var(--ot-color-surface) 40%, var(--ot-color-page)); - box-shadow: inset 4px 0 0 var(--ot-color-error); + border-color: var(--ot-color-border); + background: color-mix(in srgb, var(--ot-color-surface) 45%, var(--ot-color-page)); + box-shadow: none; :where(h2, h3, h4) { color: var(--ot-color-text); @@ -138,12 +146,12 @@ align-items: center; gap: 8px; margin: 0; - padding: 6px 10px; - border-radius: var(--ot-radius-sm); - background: color-mix(in srgb, var(--ot-color-error) 14%, var(--ot-color-surface)); - color: color-mix(in srgb, var(--ot-color-error) 60%, var(--ot-color-text)); - font-size: 0.86rem; - font-weight: 650; + // a quiet line, not a filled strip: the state is stated, not shouted + padding: 0; + background: none; + color: color-mix(in srgb, var(--ot-color-error) 55%, var(--ot-color-text)); + font-size: 0.84rem; + font-weight: 600; line-height: 1.4; mat-icon { diff --git a/src/app/unit-hub/unit-hub-details.component.scss b/src/app/unit-hub/unit-hub-details.component.scss index d5748a8705..981f0ec573 100644 --- a/src/app/unit-hub/unit-hub-details.component.scss +++ b/src/app/unit-hub/unit-hub-details.component.scss @@ -119,9 +119,8 @@ small { margin: 0; } } -// a cancelled session: accent bar and struck title in the header, the banner under it +// a cancelled session: struck title in the header, the banner under it .details-header.is-cancelled { - box-shadow: inset 4px 0 0 var(--ot-color-error); h2[mat-dialog-title] { text-decoration: line-through; text-decoration-thickness: 1px; diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index cd5549c984..3791b326bc 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -497,8 +497,8 @@ mat-icon { margin: 0; color: var(--ot-color-text-muted); } -// Cancelled: a sunken card with an error edge and accent bar, so it recedes next to -// active ones and still reads clearly. The banner carries the state in words. +// Cancelled recedes below active cards: the quietest card on the page. The banner +// carries the state in words. The banner carries the state in words. .session-card.is-cancelled { @include hub.cancelled-card; .card-preview, @@ -825,11 +825,9 @@ fieldset { margin-left: auto; } .announcement-card.is-unread { - // a 4px primary accent bar on the left, inside the rounded edge - background: var(--ot-color-surface-raised); - box-shadow: - inset 4px 0 0 var(--ot-color-primary), - var(--ot-elevation-1); + // unread: the whole card takes a light primary wash and edge + @include hub.tinted-card(var(--ot-color-primary), 4%, 22%); + box-shadow: none; .details-title { font-weight: 750; } @@ -968,14 +966,12 @@ fieldset { } } .session-card[data-timing='now'] { - box-shadow: - inset 4px 0 0 var(--ot-color-success), - var(--ot-elevation-1); + @include hub.tinted-card(var(--ot-color-success), 7%, 32%); + box-shadow: var(--ot-elevation-1); } .session-card[data-timing='soon'] { - box-shadow: - inset 4px 0 0 var(--ot-color-primary), - var(--ot-elevation-1); + @include hub.tinted-card(var(--ot-color-primary), 4%, 22%); + box-shadow: none; } // ---- Up next ---- @@ -987,14 +983,11 @@ fieldset { margin-bottom: var(--hub-space-4); padding: var(--hub-space-4); border-radius: var(--ot-radius-lg); - box-shadow: - inset 4px 0 0 var(--ot-color-link), - var(--ot-elevation-1); + @include hub.tinted-card(var(--ot-color-link), 4%, 22%); + box-shadow: none; overflow-wrap: anywhere; &[data-timing='now'] { - box-shadow: - inset 4px 0 0 var(--ot-color-success), - var(--ot-elevation-1); + @include hub.tinted-card(var(--ot-color-success), 7%, 32%); } h3 { font-size: 1rem; From 086f84a388b09f0fbef8291011bd73612f381b85 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:32:27 +1000 Subject: [PATCH 36/88] feat(unit-hub): count an announcement as read once it has been on screen --- .../unit-hub/seen-in-view.directive.spec.ts | 63 ++++++++++++++++ src/app/unit-hub/seen-in-view.directive.ts | 75 +++++++++++++++++++ src/app/unit-hub/unit-hub.component.html | 1 + src/app/unit-hub/unit-hub.component.ts | 2 + 4 files changed, 141 insertions(+) create mode 100644 src/app/unit-hub/seen-in-view.directive.spec.ts create mode 100644 src/app/unit-hub/seen-in-view.directive.ts diff --git a/src/app/unit-hub/seen-in-view.directive.spec.ts b/src/app/unit-hub/seen-in-view.directive.spec.ts new file mode 100644 index 0000000000..549caef47b --- /dev/null +++ b/src/app/unit-hub/seen-in-view.directive.spec.ts @@ -0,0 +1,63 @@ +/* eslint-disable @angular-eslint/component-max-inline-declarations */ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {Component} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {SeenInViewDirective} from './seen-in-view.directive'; + +@Component({ + standalone: true, + imports: [SeenInViewDirective], + template: `
`, +}) +class HostComponent { + seen = 0; +} + +describe('SeenInViewDirective', () => { + let callback: IntersectionObserverCallback; + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(cb: IntersectionObserverCallback) { + callback = cb; + } + observe() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const report = (ratio: number) => + callback( + [{isIntersecting: ratio > 0, intersectionRatio: ratio} as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + + it('fires once after staying mostly in view', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + report(0.8); + vi.advanceTimersByTime(1500); + report(0.9); + vi.advanceTimersByTime(3000); + expect(fixture.componentInstance.seen).toBe(1); + }); + + it('does not fire when scrolled past quickly', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + report(0.8); + vi.advanceTimersByTime(600); + report(0); + vi.advanceTimersByTime(3000); + expect(fixture.componentInstance.seen).toBe(0); + }); +}); diff --git a/src/app/unit-hub/seen-in-view.directive.ts b/src/app/unit-hub/seen-in-view.directive.ts new file mode 100644 index 0000000000..cbee978723 --- /dev/null +++ b/src/app/unit-hub/seen-in-view.directive.ts @@ -0,0 +1,75 @@ +import { + Directive, + ElementRef, + NgZone, + OnDestroy, + OnInit, + inject, + input, + output, +} from '@angular/core'; + +/** + * Emits once when the element has been mostly on screen for a moment, so an + * announcement the student actually looked at can count as read. Scrolling past + * quickly does not count. + */ +@Directive({ + selector: '[hubSeenInView]', + standalone: true, +}) +export class SeenInViewDirective implements OnInit, OnDestroy { + /** How long it has to stay in view, in ms. */ + readonly seenAfter = input(1500); + /** Share of the element that has to be visible. */ + readonly seenRatio = input(0.6); + readonly hubSeenInView = output(); + + private readonly element = inject(ElementRef); + private readonly zone = inject(NgZone); + private observer?: IntersectionObserver; + private timer?: ReturnType; + private done = false; + + ngOnInit(): void { + if (typeof IntersectionObserver === 'undefined') { + return; + } + this.zone.runOutsideAngular(() => { + this.observer = new IntersectionObserver( + ([entry]) => { + const visible = + entry.isIntersecting && + entry.intersectionRatio >= this.seenRatio() && + document.visibilityState === 'visible'; + if (visible && !this.timer && !this.done) { + this.timer = setTimeout(() => this.fire(), this.seenAfter()); + } else if (!visible) { + this.clearTimer(); + } + }, + {threshold: [0, this.seenRatio(), 1]}, + ); + this.observer.observe(this.element.nativeElement); + }); + } + + ngOnDestroy(): void { + this.clearTimer(); + this.observer?.disconnect(); + } + + private fire(): void { + this.timer = undefined; + this.done = true; + this.observer?.disconnect(); + this.zone.run(() => this.hubSeenInView.emit()); + } + + private clearTimer(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + } +} diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index 9b37cb74a9..61878d5468 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -734,6 +734,7 @@

Announcements

[class.is-pinned]="announcement.pinned" [class.is-read]="!unread" [class.is-unread]="unread" + (hubSeenInView)="unread && markRead(announcement)" >
@if (unread) { diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index b2c4575ebb..57beadf30c 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -15,6 +15,7 @@ import {DemoModeStore} from 'src/app/demo/demo-mode.store'; import {StudyEssentialsComponent} from '../study-essentials/study-essentials.component'; import {AnnouncementReadStore} from './announcement-read.store'; import {HubMarkdownPipe, HubPlainTextPipe} from './hub-markdown'; +import {SeenInViewDirective} from './seen-in-view.directive'; import {SessionTiming, sessionTiming, upNextSession} from './session-timing'; import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; import {TeamsMeetingDraft} from './teams-meeting-draft'; @@ -51,6 +52,7 @@ import {UnitHubService} from './unit-hub.service'; RouterLink, StudyEssentialsComponent, TeamsMeetingComposerComponent, + SeenInViewDirective, HubMarkdownPipe, HubPlainTextPipe, ], From f31e4f4b512b19395eb40d9581af24d3cf6ac8ff Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:32:27 +1000 Subject: [PATCH 37/88] style(unit-hub): try solid fills for live and unread cards, fade cancelled ones --- src/app/unit-hub/_hub-card.scss | 24 ++++++++++++++++++++++++ src/app/unit-hub/unit-hub.component.scss | 10 +++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/app/unit-hub/_hub-card.scss b/src/app/unit-hub/_hub-card.scss index 6869179281..b29c4653a1 100644 --- a/src/app/unit-hub/_hub-card.scss +++ b/src/app/unit-hub/_hub-card.scss @@ -130,6 +130,15 @@ border-color: var(--ot-color-border); background: color-mix(in srgb, var(--ot-color-surface) 45%, var(--ot-color-page)); box-shadow: none; + // faded back so live sessions lead; full strength again when someone points at + // it or tabs into it, so it is still easy to read on purpose + opacity: 0.55; + transition: opacity 160ms ease; + + &:hover, + &:focus-within { + opacity: 1; + } :where(h2, h3, h4) { color: var(--ot-color-text); @@ -241,3 +250,18 @@ } } } + +// Experiment: a solid state fill. The card re-points the text, border and surface +// tokens at the fill's on-colour, so chips, facts and buttons inside follow along. +@mixin solid-card($tone, $on) { + --ot-color-text: #{$on}; + --ot-color-text-muted: color-mix(in srgb, #{$on} 82%, transparent); + --ot-color-link: #{$on}; + --ot-color-primary: #{$on}; + --ot-color-border: color-mix(in srgb, #{$on} 35%, transparent); + --ot-color-surface: color-mix(in srgb, #{$on} 14%, transparent); + --ot-color-surface-raised: color-mix(in srgb, #{$on} 18%, transparent); + border-color: transparent; + background: #{$tone}; + color: #{$on}; +} diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index 3791b326bc..ec77f808e6 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -826,8 +826,8 @@ fieldset { } .announcement-card.is-unread { // unread: the whole card takes a light primary wash and edge - @include hub.tinted-card(var(--ot-color-primary), 4%, 22%); - box-shadow: none; + @include hub.solid-card(var(--ot-color-primary), var(--ot-color-on-primary)); + box-shadow: var(--ot-elevation-1); .details-title { font-weight: 750; } @@ -966,12 +966,12 @@ fieldset { } } .session-card[data-timing='now'] { - @include hub.tinted-card(var(--ot-color-success), 7%, 32%); + @include hub.solid-card(var(--ot-status-complete), var(--ot-status-complete-on)); box-shadow: var(--ot-elevation-1); } .session-card[data-timing='soon'] { - @include hub.tinted-card(var(--ot-color-primary), 4%, 22%); - box-shadow: none; + @include hub.solid-card(var(--ot-color-primary), var(--ot-color-on-primary)); + box-shadow: var(--ot-elevation-1); } // ---- Up next ---- From c88ca89ab11c83d6dbdb79a160bf5a48195c2ed0 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:36:53 +1000 Subject: [PATCH 38/88] fix(unit-hub): paint solid cards in their own colour, readable chips on fills, redraw on read --- src/app/unit-hub/_hub-card.scss | 33 +++++++++++++++++++------- src/app/unit-hub/unit-hub.component.ts | 15 +++++++++++- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/app/unit-hub/_hub-card.scss b/src/app/unit-hub/_hub-card.scss index b29c4653a1..9139fe03c0 100644 --- a/src/app/unit-hub/_hub-card.scss +++ b/src/app/unit-hub/_hub-card.scss @@ -251,17 +251,32 @@ } } -// Experiment: a solid state fill. The card re-points the text, border and surface -// tokens at the fill's on-colour, so chips, facts and buttons inside follow along. +// Experiment: a solid state fill. The fill is painted on the card first, then the +// text, border and surface tokens are re-pointed at the on-colour for its children +// only. Re-pointing them on the card itself made var(--ot-color-primary) resolve to +// the on-colour and painted the card white. @mixin solid-card($tone, $on) { - --ot-color-text: #{$on}; - --ot-color-text-muted: color-mix(in srgb, #{$on} 82%, transparent); - --ot-color-link: #{$on}; - --ot-color-primary: #{$on}; - --ot-color-border: color-mix(in srgb, #{$on} 35%, transparent); - --ot-color-surface: color-mix(in srgb, #{$on} 14%, transparent); - --ot-color-surface-raised: color-mix(in srgb, #{$on} 18%, transparent); border-color: transparent; background: #{$tone}; color: #{$on}; + + > * { + --ot-color-text: #{$on}; + --ot-color-text-muted: color-mix(in srgb, #{$on} 82%, transparent); + --ot-color-link: #{$on}; + --ot-color-primary: #{$on}; + --ot-color-border: color-mix(in srgb, #{$on} 35%, transparent); + --ot-color-surface: color-mix(in srgb, #{$on} 14%, transparent); + --ot-color-surface-raised: color-mix(in srgb, #{$on} 18%, transparent); + --ot-color-selected: color-mix(in srgb, #{$on} 20%, transparent); + --ot-color-selected-text: #{$on}; + } + + // state and type chips drop their own hue on a fill: an on-colour glass chip, so a + // green "Happening now" never sits green on green + :where(.chip, .unit-badge, .kind-chip) { + border-color: color-mix(in srgb, #{$on} 40%, transparent); + background: color-mix(in srgb, #{$on} 20%, transparent); + color: #{$on}; + } } diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index 57beadf30c..055265c57f 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -1,5 +1,13 @@ import {CommonModule} from '@angular/common'; -import {ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit, inject} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + NgZone, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatDialog, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; @@ -100,6 +108,7 @@ export class UnitHubComponent implements OnInit, OnDestroy { now = Date.now(); private clock?: ReturnType; private readonly zone = inject(NgZone); + private readonly changes = inject(ChangeDetectorRef); private readonly snackBar = inject(MatSnackBar); /** An announcement or session named in the URL, opened once the feed has loaded. */ private pendingLink: {announcement: number; session: number} | null = null; @@ -229,10 +238,14 @@ export class UnitHubComponent implements OnInit, OnDestroy { markRead(row: UnitAnnouncement): void { if (this.announcements.includes(row)) { this.readStatus.markRead([row]); + // the read state lives outside Angular's inputs, so ask for a redraw now rather + // than on the next unrelated change + this.changes.markForCheck(); } } markAllRead(): void { this.readStatus.markRead(this.announcements.filter((row) => this.readStatus.isUnread(row))); + this.changes.markForCheck(); } isManagedExternally(row: UnitAnnouncement): boolean { return row.managed_externally === true || row.source_provider === 'microsoft_teams'; From 6f3a7b5389866d473d373f09c07a135dfb5c5ebf Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:42:03 +1000 Subject: [PATCH 39/88] revert(unit-hub): mark announcements read only when the student chooses to --- .../unit-hub/seen-in-view.directive.spec.ts | 63 ---------------- src/app/unit-hub/seen-in-view.directive.ts | 75 ------------------- src/app/unit-hub/unit-hub.component.html | 1 - src/app/unit-hub/unit-hub.component.ts | 2 - 4 files changed, 141 deletions(-) delete mode 100644 src/app/unit-hub/seen-in-view.directive.spec.ts delete mode 100644 src/app/unit-hub/seen-in-view.directive.ts diff --git a/src/app/unit-hub/seen-in-view.directive.spec.ts b/src/app/unit-hub/seen-in-view.directive.spec.ts deleted file mode 100644 index 549caef47b..0000000000 --- a/src/app/unit-hub/seen-in-view.directive.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable @angular-eslint/component-max-inline-declarations */ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {Component} from '@angular/core'; -import {TestBed} from '@angular/core/testing'; -import {SeenInViewDirective} from './seen-in-view.directive'; - -@Component({ - standalone: true, - imports: [SeenInViewDirective], - template: `
`, -}) -class HostComponent { - seen = 0; -} - -describe('SeenInViewDirective', () => { - let callback: IntersectionObserverCallback; - - beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal( - 'IntersectionObserver', - class { - constructor(cb: IntersectionObserverCallback) { - callback = cb; - } - observe() {} - disconnect() {} - }, - ); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - const report = (ratio: number) => - callback( - [{isIntersecting: ratio > 0, intersectionRatio: ratio} as IntersectionObserverEntry], - {} as IntersectionObserver, - ); - - it('fires once after staying mostly in view', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.detectChanges(); - report(0.8); - vi.advanceTimersByTime(1500); - report(0.9); - vi.advanceTimersByTime(3000); - expect(fixture.componentInstance.seen).toBe(1); - }); - - it('does not fire when scrolled past quickly', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.detectChanges(); - report(0.8); - vi.advanceTimersByTime(600); - report(0); - vi.advanceTimersByTime(3000); - expect(fixture.componentInstance.seen).toBe(0); - }); -}); diff --git a/src/app/unit-hub/seen-in-view.directive.ts b/src/app/unit-hub/seen-in-view.directive.ts deleted file mode 100644 index cbee978723..0000000000 --- a/src/app/unit-hub/seen-in-view.directive.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - Directive, - ElementRef, - NgZone, - OnDestroy, - OnInit, - inject, - input, - output, -} from '@angular/core'; - -/** - * Emits once when the element has been mostly on screen for a moment, so an - * announcement the student actually looked at can count as read. Scrolling past - * quickly does not count. - */ -@Directive({ - selector: '[hubSeenInView]', - standalone: true, -}) -export class SeenInViewDirective implements OnInit, OnDestroy { - /** How long it has to stay in view, in ms. */ - readonly seenAfter = input(1500); - /** Share of the element that has to be visible. */ - readonly seenRatio = input(0.6); - readonly hubSeenInView = output(); - - private readonly element = inject(ElementRef); - private readonly zone = inject(NgZone); - private observer?: IntersectionObserver; - private timer?: ReturnType; - private done = false; - - ngOnInit(): void { - if (typeof IntersectionObserver === 'undefined') { - return; - } - this.zone.runOutsideAngular(() => { - this.observer = new IntersectionObserver( - ([entry]) => { - const visible = - entry.isIntersecting && - entry.intersectionRatio >= this.seenRatio() && - document.visibilityState === 'visible'; - if (visible && !this.timer && !this.done) { - this.timer = setTimeout(() => this.fire(), this.seenAfter()); - } else if (!visible) { - this.clearTimer(); - } - }, - {threshold: [0, this.seenRatio(), 1]}, - ); - this.observer.observe(this.element.nativeElement); - }); - } - - ngOnDestroy(): void { - this.clearTimer(); - this.observer?.disconnect(); - } - - private fire(): void { - this.timer = undefined; - this.done = true; - this.observer?.disconnect(); - this.zone.run(() => this.hubSeenInView.emit()); - } - - private clearTimer(): void { - if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; - } - } -} diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index 61878d5468..9b37cb74a9 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -734,7 +734,6 @@

Announcements

[class.is-pinned]="announcement.pinned" [class.is-read]="!unread" [class.is-unread]="unread" - (hubSeenInView)="unread && markRead(announcement)" >
@if (unread) { diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index 055265c57f..ffefdab895 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -23,7 +23,6 @@ import {DemoModeStore} from 'src/app/demo/demo-mode.store'; import {StudyEssentialsComponent} from '../study-essentials/study-essentials.component'; import {AnnouncementReadStore} from './announcement-read.store'; import {HubMarkdownPipe, HubPlainTextPipe} from './hub-markdown'; -import {SeenInViewDirective} from './seen-in-view.directive'; import {SessionTiming, sessionTiming, upNextSession} from './session-timing'; import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; import {TeamsMeetingDraft} from './teams-meeting-draft'; @@ -60,7 +59,6 @@ import {UnitHubService} from './unit-hub.service'; RouterLink, StudyEssentialsComponent, TeamsMeetingComposerComponent, - SeenInViewDirective, HubMarkdownPipe, HubPlainTextPipe, ], From b963a399dcd2e693c134afd1108e37f1d63f8915 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:42:27 +1000 Subject: [PATCH 40/88] fix(unit-hub): show an opened announcement as read straight away --- src/app/unit-hub/unit-hub.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/unit-hub/unit-hub.component.ts b/src/app/unit-hub/unit-hub.component.ts index ffefdab895..fa9f8ccbdd 100644 --- a/src/app/unit-hub/unit-hub.component.ts +++ b/src/app/unit-hub/unit-hub.component.ts @@ -423,6 +423,7 @@ export class UnitHubComponent implements OnInit, OnDestroy { return; } this.readStatus.markRead([announcement]); + this.changes.markForCheck(); this.showDetails({ unitCode: unit.code, unitName: unit.name, From dae9acfb4474299227185c210b034c8c9c54efa6 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:51:51 +1000 Subject: [PATCH 41/88] Give the peer progress card a clearer hierarchy Show the headline percentage once with a slim track and a single submitted line, drop the repeated metric tiles and bar end labels, and lay the breakdown out as a flat stacked bar over a two-column legend. Legend rows and segments highlight on hover or focus with a small tooltip, bars grow in on the first result only, and every row can shrink so the card stays within its column. --- .../ppi-widget/ppi-widget.component.html | 176 +++--- .../ppi-widget/ppi-widget.component.scss | 511 +++++++++++------- .../ppi-widget/ppi-widget.component.spec.ts | 106 +++- .../ppi-widget/ppi-widget.component.ts | 68 +++ 4 files changed, 587 insertions(+), 274 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html index ea67a1f685..c517cfd623 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html @@ -17,8 +17,8 @@ - - Peer Progress Indicator + + Peer progress Anonymous peers at your target grade
@@ -34,16 +34,16 @@ [attr.aria-label]="advancedToggleLabel" (click)="setAdvanced(!advanced)" > + Advanced - Advanced } @if (isCompactResult) { -
+
-
- -
- +
+
+ + @if (hasCompletedPercentage && hasSubmittedPercentage) { +

+ + {{ formatPercentage(view.data?.submittedPercentage ?? 0) }}% have submitted +

+ }
@if (advanced) { -
-
- @if (hasCompletedPercentage) { -
- Completed - {{ formatPercentage(view.data?.completedPercentage ?? 0) }}% -
- } - @if (hasSubmittedPercentage) { - - } -
- +
-
- Task status breakdown - Independently privacy-rounded; values may not total 100% -
+ Where peers are + Independently privacy-rounded; values may not total 100%
@if (!view.data?.distributionAvailable) { @@ -121,31 +111,69 @@
} @else if (usesStackedDistribution) { - +
+
+ -
    - @for (segment of displaySegments; track segment.status) { -
  • + @if (highlightedSegment; as active) { - {{ segment.label }} - {{ formatPercentage(segment.percentage) }}% -
  • - } -
+ class="ppi-tooltip" + [style.--ppi-tooltip-left]="segmentMidpoint(active.status)" + > + {{ active.label }} · {{ formatPercentage(active.percentage) }}% + + } +
+ +
    + @for (segment of displaySegments; track segment.status) { +
  • + + {{ segment.label }} + {{ formatPercentage(segment.percentage) }}% +
  • + } +
+
} @else {

@@ -162,7 +190,7 @@ class="ppi-swatch" [style.background-color]="segment.color" > - {{ segment.label }} + {{ segment.label }}

} - - @if (view.data?.lastUpdatedAt) { - - Updated {{ view.data?.lastUpdatedAt | date: 'd MMM, h:mm a' }} - - } } + + @if (view.data?.lastUpdatedAt) { +
+ + Updated {{ view.data?.lastUpdatedAt | date: 'd MMM, h:mm a' }} + +
+ } } @else {
@switch (view.state) { diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.scss index 60207a2e67..58b042d3ce 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.scss @@ -1,63 +1,67 @@ :host { container-type: inline-size; display: block; + max-width: 100%; min-width: 0; } +$ppi-ease-out: cubic-bezier(0.23, 1, 0.32, 1); + // A flat card like the other task cards: surface, hairline border, the shared -// radius and elevation. The corner glow and the coloured state borders are gone; -// the state icon and the badge in the header already say when data is stale or -// failed, so the frame no longer shouts it as well. +// radius and elevation. Sections are separated by spacing, not rules. .ppi-card { background: var(--ot-color-surface-raised); border: 1px solid var(--ot-color-divider); border-radius: var(--ot-radius-md); - box-sizing: border-box; box-shadow: var(--ot-elevation-1); + box-sizing: border-box; color: var(--ot-color-text); + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr); max-width: 100%; - overflow: hidden; + min-width: 0; + overflow: clip; + padding: 1rem 1.25rem; &.is-error { border-color: var(--ot-color-error); } + &.is-warning { border-color: var(--ot-color-warning); } - &.is-muted { - background: var(--ot-color-surface); - border-color: var(--ot-color-border); - } } +// ---------------------------------------------------------------- Header + .ppi-head { - align-items: center; + align-items: flex-start; display: flex; - gap: 10px; + gap: 12px; justify-content: space-between; - padding: 1rem 1rem 0.5rem; + min-width: 0; } .ppi-identity { align-items: center; display: flex; - gap: 8px; + gap: 10px; min-width: 0; +} - > span:last-child { - min-width: 0; - } +.ppi-heading-text { + min-width: 0; } .ppi-avatar { align-items: center; background: var(--ot-color-selected); - border: 1px solid var(--ot-color-divider); border-radius: 50%; color: var(--ot-color-primary); display: inline-flex; - flex: 0 0 2.5rem; - height: 2.5rem; + flex: 0 0 2.25rem; + height: 2.25rem; justify-content: center; mat-icon { @@ -74,60 +78,73 @@ .ppi-title { font-size: 1rem; - font-weight: 700; + font-weight: 600; letter-spacing: -0.01em; - line-height: 1.25; + line-height: 1.25rem; } .ppi-subtitle { color: var(--ot-color-text-muted); font-size: 0.8125rem; - line-height: 1.3; - margin-top: 1px; + line-height: 1.125rem; } +// A compact labelled switch. Its visual box is one title line tall so it sits on +// the title row, and a pseudo-element keeps a 44px touch target around it. .ppi-toggle { align-items: center; appearance: none; background: transparent; border: 0; border-radius: 999px; - color: var(--ot-color-text); + color: var(--ot-color-text-muted); cursor: pointer; display: inline-flex; flex: 0 0 auto; font: inherit; font-size: 0.8125rem; - font-weight: 600; - gap: 5px; - min-height: 44px; - padding: 0.5rem 0.25rem; + font-weight: 500; + gap: 8px; + height: 1.25rem; + padding: 0; + position: relative; white-space: nowrap; + &::before { + content: ''; + inset: -12px -8px; + position: absolute; + } + &:focus-visible { outline: 2px solid var(--ot-color-focus); - outline-offset: 2px; + outline-offset: 4px; + } + + &[aria-checked='true'] { + color: var(--ot-color-text); } } .ppi-toggle-track { - background: var(--ot-color-border); + background: var(--ot-color-control-border); border-radius: 999px; + box-sizing: border-box; display: inline-flex; - flex: 0 0 2.25rem; - height: 1.25rem; + flex: 0 0 2rem; + height: 1.125rem; padding: 2px; - transition: background 160ms ease; + transition: background-color 160ms ease; } .ppi-toggle-thumb { - background: var(--ot-color-surface); + background: var(--ot-color-surface-raised); border-radius: 50%; - box-shadow: 0 1px 2px rgb(0 0 0 / 28%); - height: 1rem; + box-shadow: var(--ot-elevation-1); + height: 0.875rem; transform: translateX(0); - transition: transform 160ms ease; - width: 1rem; + transition: transform 160ms $ppi-ease-out; + width: 0.875rem; } .ppi-toggle[aria-checked='true'] .ppi-toggle-track { @@ -135,150 +152,161 @@ } .ppi-toggle[aria-checked='true'] .ppi-toggle-thumb { - transform: translateX(1rem); + transform: translateX(0.875rem); } +// ---------------------------------------------------------------- Summary + .ppi-summary { - align-items: center; display: grid; - gap: 12px; - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); - padding: 0.25rem 1rem 1rem 4.5rem; + gap: 0.625rem; + min-width: 0; } .ppi-value { align-items: baseline; + column-gap: 0.625rem; display: flex; - gap: 6px; + flex-wrap: wrap; min-width: 0; + row-gap: 2px; strong { - color: var(--ot-color-primary); - font-size: clamp(1.75rem, 7cqi, 2.5rem); - letter-spacing: -0.04em; + color: var(--ot-color-text); + font-size: 2.25rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + letter-spacing: -0.02em; line-height: 1; } span { + color: var(--ot-color-text); font-size: 0.875rem; - font-weight: 600; - line-height: 1.25; + line-height: 1.3; + min-width: 0; } } -.ppi-visual { - min-width: 0; -} - -.ppi-track, -.ppi-independent__track { - border-radius: 999px; - overflow: hidden; -} - .ppi-track { background: var(--ot-meter-track); - height: 9px; + border-radius: 999px; + height: 8px; + overflow: hidden; } -.ppi-fill, -.ppi-independent__fill { +.ppi-fill { + background: var(--ot-status-complete-graphic, var(--ot-color-success)); border-radius: inherit; display: block; height: 100%; + transform-origin: left center; + transition: width 200ms ease; } -.ppi-fill { - background: var(--ot-color-primary); - transition: width 260ms ease; +.ppi-track.is-submitted .ppi-fill { + background: var(--ot-status-ready-for-feedback-graphic, var(--ot-color-info)); } -.ppi-scale { +.ppi-secondary { + align-items: center; color: var(--ot-color-text-muted); display: flex; - font-size: 10px; - font-weight: 600; - justify-content: space-between; - margin-top: 3px; -} - -.ppi-advanced { - background: var(--ot-color-surface-raised); - border-top: 1px solid var(--ot-color-divider); - display: grid; - gap: 9px; - padding: 1rem; -} - -.ppi-metrics { - display: grid; + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; gap: 6px; - grid-template-columns: repeat(2, minmax(0, 1fr)); + line-height: 1.3; + margin: 0; } -.ppi-metric { - align-items: center; - background: var(--ot-color-surface); - border: 1px solid var(--ot-color-divider); - border-left: 4px solid; - border-radius: 6px; - display: flex; - gap: 12px; - justify-content: space-between; - min-width: 0; - padding: 5px 8px; - - span { - color: var(--ot-color-text-muted); - font-size: 12px; - font-weight: 600; - } +.ppi-dot { + border-radius: 50%; + flex: 0 0 8px; + height: 8px; + width: 8px; - strong { - font-size: 16px; + &.is-submitted { + background: var(--ot-status-ready-for-feedback-graphic, var(--ot-color-info)); } +} - &.is-complete { - border-left-color: var(--ot-color-success); - } +// ---------------------------------------------------------------- Breakdown - &.is-submitted { - border-left-color: var(--ot-color-info); - } +.ppi-advanced { + display: grid; + gap: 0.75rem; + min-width: 0; + padding-top: 0.25rem; } .ppi-heading { - small { - display: block; - } + display: grid; + gap: 2px; strong { - font-size: 14px; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.3; } small { color: var(--ot-color-text-muted); - font-size: 11px; - margin-top: 2px; + font-size: 0.75rem; + line-height: 1.35; } } +.ppi-breakdown { + display: grid; + gap: 0.75rem; + min-width: 0; +} + +.ppi-distribution-wrap { + position: relative; +} + +// One flat track. The 2px gaps are transparent, so the card surface shows +// through and neighbouring segments stay separable in both themes. .ppi-distribution { - background: var(--ot-meter-track); - border: 2px solid var(--ot-color-surface); border-radius: 999px; - box-shadow: 0 1px 4px rgb(0 0 0 / 18%); display: flex; - height: 14px; + gap: 2px; + height: 10px; overflow: hidden; } .ppi-segment { flex-basis: 0; + min-width: 2px; + transform-origin: left center; + transition: + flex-grow 200ms ease, + opacity 150ms ease; +} - & + & { - border-left: 1px solid rgb(255 255 255 / 58%); - } +// Hidden until emphasis is styled; see the focus and hover rules below. +.ppi-tooltip { + background: var(--ot-color-inverse-surface); + border-radius: 6px; + bottom: calc(100% + 6px); + box-shadow: var(--ot-elevation-1); + color: var(--ot-color-inverse-text); + display: none; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + font-weight: 500; + left: clamp(4.5rem, var(--ppi-tooltip-left, 50%), calc(100% - 4.5rem)); + line-height: 1.2; + max-width: 9rem; + overflow: hidden; + padding: 4px 8px; + pointer-events: none; + position: absolute; + text-overflow: ellipsis; + transform: translateX(-50%); + white-space: nowrap; + z-index: 1; } .ppi-legend, @@ -290,65 +318,111 @@ .ppi-legend { display: grid; - gap: 4px 6px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0 1.5rem; + grid-template-columns: minmax(0, 1fr); li { align-items: center; - background: var(--ot-color-surface); - border: 1px solid var(--ot-color-divider); - border-radius: 5px; + border-radius: 6px; display: grid; - font-size: 10px; - gap: 5px; - grid-template-columns: 9px minmax(0, 1fr) auto; - min-height: 20px; - padding: 2px 4px; + font-size: 0.8125rem; + gap: 8px; + grid-template-columns: 8px minmax(0, 1fr) auto; + line-height: 1.3; + margin-inline: -6px; + min-height: 1.75rem; + min-width: 0; + padding: 2px 6px; + transition: background-color 150ms ease; + + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: -2px; + } + + strong { + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; + } } } .ppi-swatch { - border: 1px solid rgb(0 0 0 / 12%); border-radius: 50%; - height: 9px; - width: 9px; + height: 8px; + width: 8px; } .ppi-name { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +// Emphasis: keyboard focus always shows it; pointer hover only where hover is real. +.ppi-breakdown.is-focus-highlighting { + .ppi-segment:not(.is-active) { + opacity: 0.4; + } + + .ppi-legend li.is-active { + background: var(--ot-color-hover); + } +} + +.ppi-breakdown.is-focus-highlighting .ppi-tooltip { + display: block; +} + +@media (hover: hover) and (pointer: fine) { + .ppi-breakdown.is-hover-highlighting { + .ppi-segment:not(.is-active) { + opacity: 0.4; + } + + .ppi-legend li.is-active { + background: var(--ot-color-hover); + } + + .ppi-tooltip { + display: block; + } + } +} + +// ---------------------------------------------------------------- Independent scale + .ppi-independent { display: grid; - gap: 7px; + gap: 0.75rem; + min-width: 0; } .ppi-rounding { - background: var(--ot-color-surface); - border-left: 3px solid var(--ot-color-primary); - border-radius: 6px; color: var(--ot-color-text-muted); - font-size: 11px; + font-size: 0.75rem; line-height: 1.45; margin: 0; - padding: 5px 7px; } .ppi-list { display: grid; - gap: 5px; + gap: 0.5rem; li { align-items: center; display: grid; - font-size: 11px; - gap: 7px; - grid-template-columns: minmax(110px, 0.55fr) minmax(80px, 1fr) 34px; + font-size: 0.8125rem; + gap: 10px; + grid-template-columns: minmax(0, 9rem) minmax(0, 1fr) 2.75rem; + min-width: 0; } strong { + font-variant-numeric: tabular-nums; + font-weight: 600; text-align: right; } } @@ -356,51 +430,78 @@ .ppi-status { align-items: center; display: flex; - font-weight: 600; - gap: 7px; + gap: 8px; min-width: 0; -} -.ppi-status .ppi-swatch { - flex: 0 0 9px; + .ppi-swatch { + flex: 0 0 8px; + } } .ppi-independent__track { background: var(--ot-meter-track); + border-radius: 999px; display: block; - height: 9px; + height: 8px; + overflow: hidden; +} + +.ppi-independent__fill { + border-radius: inherit; + display: block; + height: 100%; + transition: width 200ms ease; } .ppi-notice { align-items: flex-start; - background: var(--ot-color-surface-raised); - border: 1px solid var(--ot-color-warning); - border-radius: 8px; - color: var(--ot-color-warning); + background: var(--ot-color-surface); + border-radius: var(--ot-radius-sm); + color: var(--ot-color-text-muted); display: flex; - font-size: 12px; + font-size: 0.8125rem; gap: 10px; line-height: 1.45; - padding: 8px 9px; + padding: 0.625rem 0.75rem; + + > mat-icon { + color: var(--ot-color-text-muted); + flex: 0 0 auto; + font-size: 20px; + height: 20px; + width: 20px; + } strong { + color: var(--ot-color-text); display: block; + font-weight: 600; margin-bottom: 1px; } } +// ---------------------------------------------------------------- Footer + +.ppi-footer { + margin-top: -0.25rem; +} + .ppi-updated { color: var(--ot-color-text-muted); - font-size: 10px; - justify-self: end; + display: block; + font-size: 0.75rem; + line-height: 1.3; } +// ---------------------------------------------------------------- Non-result states + .ppi-state { align-items: center; display: flex; - gap: 12px; - min-height: 42px; - padding: 4px 16px 14px 58px; + flex-wrap: wrap; + gap: 8px 12px; + min-height: 2.5rem; + min-width: 0; > mat-icon { color: var(--ot-color-text-muted); @@ -409,14 +510,16 @@ > span:not(.ppi-badge) { color: var(--ot-color-text-muted); - flex: 1 1 auto; - font-size: 12px; + flex: 1 1 12rem; + font-size: 0.8125rem; line-height: 1.4; + min-width: 0; strong { color: var(--ot-color-text); display: block; - font-size: 13px; + font-size: 0.875rem; + font-weight: 600; margin-bottom: 1px; } } @@ -431,63 +534,57 @@ } .ppi-badge { - background: var(--ot-color-surface-raised); + background: var(--ot-color-surface); border-radius: 999px; color: var(--ot-color-warning); - font-size: 10px; - font-weight: 700; - padding: 4px 8px; + font-size: 0.75rem; + font-weight: 600; + padding: 3px 8px; white-space: nowrap; } -@container (width < 460px) { - .ppi-head { - align-items: flex-start; - } +// ---------------------------------------------------------------- Entrance motion - .ppi-subtitle { - max-width: 25rem; +@keyframes ppi-grow { + from { + transform: scaleX(0); } - .ppi-summary { - padding-left: 16px; + to { + transform: scaleX(1); } +} - .ppi-state { - align-items: flex-start; - flex-wrap: wrap; - padding-left: 16px; - - button { - margin-left: 30px; - } - } +.ppi-summary.is-entering .ppi-fill { + animation: ppi-grow 400ms $ppi-ease-out both; } -@container (width < 350px) { - .ppi-head { - flex-wrap: wrap; - } +.ppi-advanced.is-entering .ppi-segment { + animation: ppi-grow 400ms $ppi-ease-out both; + animation-delay: calc(var(--ppi-index, 0) * 30ms); +} - .ppi-toggle { - margin-left: 42px; - } +// ---------------------------------------------------------------- Widths - .ppi-value strong { - font-size: 25px; +@container (width >= 420px) { + .ppi-legend { + grid-auto-flow: column; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(var(--ppi-legend-rows, 1), auto); } +} - .ppi-summary { - grid-template-columns: 1fr; +@container (width < 380px) { + .ppi-card { + padding: 1rem; } - .ppi-legend { - grid-template-columns: 1fr; + .ppi-value strong { + font-size: 2rem; } .ppi-list li { - align-items: start; - grid-template-columns: 1fr auto; + grid-template-columns: minmax(0, 1fr) auto; } .ppi-independent__track { @@ -496,10 +593,24 @@ } } +@container (width < 300px) { + .ppi-head { + flex-wrap: wrap; + } +} + @media (prefers-reduced-motion: reduce) { .ppi-fill, + .ppi-segment, + .ppi-independent__fill, + .ppi-legend li, .ppi-toggle-track, .ppi-toggle-thumb { transition: none; } + + .ppi-summary.is-entering .ppi-fill, + .ppi-advanced.is-entering .ppi-segment { + animation: none; + } } diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.spec.ts index 27cfc5ba2a..702b1f2ea5 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.spec.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.spec.ts @@ -101,6 +101,30 @@ describe('PpiWidgetComponent', () => { expect(fixture.nativeElement.querySelector('.ppi-fill').style.width).toBe('0%'); }); + it('shows the headline percentage once, with submitted as a single secondary line', () => { + load(of(NORMAL_STATE)); + + const card = fixture.nativeElement.querySelector('.ppi-card') as HTMLElement; + expect(card.textContent.match(/(^|\D)10%/g)?.length).toBe(1); + expect(card.querySelector('.ppi-value strong')?.textContent).toBe('10%'); + expect(card.querySelector('.ppi-secondary')?.textContent.trim()).toBe('60% have submitted'); + expect(card.querySelectorAll('.ppi-track').length).toBe(1); + expect(card.querySelector('.ppi-metric')).toBeNull(); + expect(card.querySelector('.ppi-scale')).toBeNull(); + }); + + it('renders the suppressed state as a calm message with no bars', () => { + load(of(SUPPRESSED_STATE)); + + const card = fixture.nativeElement.querySelector('.ppi-card') as HTMLElement; + expect(card.querySelector('.ppi-state mat-icon')?.textContent).toContain('privacy_tip'); + expect(card.textContent).toContain('Progress is hidden to protect privacy'); + expect(card.querySelector('.ppi-track')).toBeNull(); + expect(card.querySelector('.ppi-distribution')).toBeNull(); + expect(card.querySelector('[role="progressbar"]')).toBeNull(); + expect(card.querySelector('button[role="switch"]')).toBeNull(); + }); + it('shows the API-provided hidden message for a suppressed response', () => { load(of(SUPPRESSED_STATE)); expect(component.view.state).toBe('hidden'); @@ -282,7 +306,7 @@ describe('PpiWidgetComponent', () => { fixture.detectChanges(); const text = fixture.nativeElement.textContent; - expect(text).toContain('Task status breakdown'); + expect(text).toContain('Where peers are'); expect(text).toContain('Redo'); expect(text).toContain('Resubmit'); expect(fixture.nativeElement.querySelector('.ppi-distribution')).toBeTruthy(); @@ -303,6 +327,86 @@ describe('PpiWidgetComponent', () => { ]); }); + it('lists every non-zero status with its percentage in the legend and the bar label', () => { + load(of(NORMAL_STATE)); + component.setAdvanced(true); + fixture.detectChanges(); + + const rows = Array.from( + fixture.nativeElement.querySelectorAll('.ppi-legend li'), + ).map((row) => [ + row.querySelector('.ppi-name')?.textContent.trim(), + row.querySelector('strong')?.textContent.trim(), + ]); + expect(rows).toEqual([ + ['Not Started', '20%'], + ['Working On It', '20%'], + ['Ready for Feedback', '20%'], + ['Resubmit', '10%'], + ['Redo', '10%'], + ['Complete', '10%'], + ['Fail', '10%'], + ]); + + const bar = fixture.nativeElement.querySelector('.ppi-distribution') as HTMLElement; + expect(bar.getAttribute('role')).toBe('img'); + const label = bar.getAttribute('aria-label') ?? ''; + rows.forEach(([name, percentage]) => { + expect(label).toContain(`${name} ${percentage}`); + }); + expect(label).not.toContain('Discuss'); + expect(fixture.nativeElement.querySelectorAll('.ppi-segment').length).toBe(7); + }); + + it('highlights the matching segment while a legend row is hovered or focused', () => { + load(of(NORMAL_STATE)); + component.setAdvanced(true); + fixture.detectChanges(); + + const breakdown = fixture.nativeElement.querySelector('.ppi-breakdown') as HTMLElement; + const row = fixture.nativeElement.querySelector( + '.ppi-legend li[data-status="redo"]', + ) as HTMLElement; + + row.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + + expect(component.highlightedStatus).toBe('redo'); + expect(breakdown.classList).toContain('is-hover-highlighting'); + expect( + fixture.nativeElement.querySelector('.ppi-segment[data-status="redo"]').classList, + ).toContain('is-active'); + expect( + fixture.nativeElement.querySelector('.ppi-segment[data-status="complete"]').classList, + ).not.toContain('is-active'); + expect(fixture.nativeElement.querySelector('.ppi-tooltip').textContent.trim()).toBe( + 'Redo · 10%', + ); + + row.dispatchEvent(new MouseEvent('mouseleave')); + fixture.detectChanges(); + expect(component.highlightedStatus).toBeNull(); + expect(fixture.nativeElement.querySelector('.ppi-tooltip')).toBeNull(); + + row.dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + expect(component.highlightedStatus).toBe('redo'); + expect(breakdown.classList).toContain('is-focus-highlighting'); + expect(row.getAttribute('tabindex')).toBe('0'); + }); + + it('plays the entrance only for the first result, not on a refresh', () => { + load(of(NORMAL_STATE)); + expect(component.animateEntry).toBe(true); + expect(fixture.nativeElement.querySelector('.ppi-summary').classList).toContain('is-entering'); + + load(of(NORMAL_STATE)); + expect(component.animateEntry).toBe(false); + expect(fixture.nativeElement.querySelector('.ppi-summary').classList).not.toContain( + 'is-entering', + ); + }); + it.each([ {state: ROUNDED_90_STATE, total: 90}, {state: ROUNDED_110_STATE, total: 110}, diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.ts index f38e0c3dbd..8464cbb6d6 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.ts @@ -17,6 +17,8 @@ import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; import {PeerProgressIndicatorService} from 'src/app/api/services/peer-progress-indicator.service'; import {PeerProgressDisplayPreferenceService} from 'src/app/common/services/peer-progress-display-preference.service'; +type PeerProgressHighlightSource = 'pointer' | 'focus'; + interface PeerProgressDisplaySegment { status: TaskStatusEnum; label: string; @@ -38,6 +40,16 @@ export class PpiWidgetComponent implements OnChanges, OnDestroy { view: PeerProgressViewModel = {state: 'loading', data: null, message: null}; advanced = false; + // The status whose segment is emphasised, and whether a pointer or keyboard + // focus asked for it. Hover emphasis is only styled on fine-pointer devices. + highlightedStatus: TaskStatusEnum | null = null; + highlightSource: PeerProgressHighlightSource | null = null; + + // Bars grow in only the first time a result is shown. Later refreshes change + // widths in place without replaying the entrance. + animateEntry = false; + + private hasShownResult = false; private activeRequest?: Subscription; private readonly statusDisplayIndex = new Map( TaskStatus.PEER_PROGRESS_DISPLAY_ORDER.map((status, index) => [status, index]), @@ -130,6 +142,55 @@ export class PpiWidgetComponent implements OnChanges, OnDestroy { return `Anonymous peer task status distribution: ${detail}`; } + get highlightedSegment(): PeerProgressDisplaySegment | null { + if (this.highlightedStatus === null) { + return null; + } + return ( + this.displaySegments.find((segment) => segment.status === this.highlightedStatus) ?? null + ); + } + + // Rows per legend column, so a two-column legend fills top to bottom in + // display order instead of leaving a lone item on a final row. + get legendRows(): number { + return Math.max(1, Math.ceil(this.displaySegments.length / 2)); + } + + get breakdownHeadingId(): string { + return `peer-progress-breakdown-${this.taskDef?.id ?? 'loading'}`; + } + + highlight(status: TaskStatusEnum, source: PeerProgressHighlightSource): void { + this.highlightedStatus = status; + this.highlightSource = source; + this.cdr.markForCheck(); + } + + clearHighlight(source: PeerProgressHighlightSource): void { + if (this.highlightSource !== source) { + return; + } + this.highlightedStatus = null; + this.highlightSource = null; + this.cdr.markForCheck(); + } + + // Horizontal centre of a segment in the stacked bar, used to place its tooltip. + segmentMidpoint(status: TaskStatusEnum): string { + const segments = this.displaySegments; + const total = this.distributionTotal || 1; + let offset = 0; + + for (const segment of segments) { + if (segment.status === status) { + return `${((offset + segment.percentage / 2) / total) * 100}%`; + } + offset += segment.percentage; + } + return '50%'; + } + get titleId(): string { return `peer-progress-title-${this.taskDef?.id ?? 'loading'}`; } @@ -182,6 +243,13 @@ export class PpiWidgetComponent implements OnChanges, OnDestroy { private setView(next: PeerProgressViewModel): void { this.view = next; + this.highlightedStatus = null; + this.highlightSource = null; + + if (next.state === 'success' || next.state === 'no-data') { + this.animateEntry = !this.hasShownResult; + this.hasShownResult = true; + } this.cdr.markForCheck(); } From 38512bea727cd5a2a7f522dd81f57d9ff28f2f60 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:53:19 +1000 Subject: [PATCH 42/88] fix(extension): tidy the request extension dialog Show the task and its current due date under the title, warn when the task is already past due, and shorten the description. The reason field grows with its text and its counter warns near the limit. The date field has a label, the allowed range and a summary of the weeks requested. Actions use the shared filled and outlined buttons with a spinner while sending, and stack on phones. --- .../extension-modal.component.html | 114 +++++++--- .../extension-modal.component.scss | 151 ++++++++++++- .../extension-modal.component.spec.ts | 199 +++++++++++++++++- .../extension-modal.component.ts | 88 +++++++- .../extension-modal.service.spec.ts | 2 +- .../extension-modal.service.ts | 2 +- 6 files changed, 507 insertions(+), 49 deletions(-) diff --git a/src/app/common/modals/extension-modal/extension-modal.component.html b/src/app/common/modals/extension-modal/extension-modal.component.html index c304d2872d..cf49e4db3c 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.html +++ b/src/app/common/modals/extension-modal/extension-modal.component.html @@ -1,22 +1,51 @@ -

Request extension

+

Request an extension

-

- Please explain why you require an extension for this task, the teaching team will assess the - request shortly. +

+ @if (data.task.definition) { + + {{ data.task.definition.abbreviation }} + {{ data.task.definition.name }} + + } + + + Due {{ formatShortDate(dueDate) }} + +
+ + @if (daysPastDue > 0) { +

+ + This task is {{ daysPastDue }} {{ daysPastDue === 1 ? 'day' : 'days' }} past its due + date +

+ } + +

+ Tell your teaching team why you need more time. They'll review your request and reply in the + task comments.

- + Reason - {{ extensionData.controls.extensionReason.value.length }} / {{ reasonMaxLength }}{{ reasonLength }} / {{ reasonMaxLength }} @if (extensionData.controls.extensionReason.hasError('required')) { You must enter a reason @@ -29,40 +58,59 @@

Request extension

}
- - Choose a date - - - - +
+ + New due date + + + + @if (dateRangeText) { + Choose a date from {{ dateRangeText }} + } + + + @if (dateNeedsAttention) { +

Pick a date from {{ dateRangeText }}

+ } @else if (extensionSummary) { + {{ extensionSummary }} + } +
@if (errorMessage) { }
-
- +
+
diff --git a/src/app/common/modals/extension-modal/extension-modal.component.scss b/src/app/common/modals/extension-modal/extension-modal.component.scss index ed62ef2a07..f02ef2f2d4 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.scss +++ b/src/app/common/modals/extension-modal/extension-modal.component.scss @@ -1,29 +1,162 @@ +@use '../../../unit-hub/hub-card' as hub; + +@include hub.card-theme; + :host { display: block; max-width: 100%; } +.ext-dialog-title { + padding-inline: 24px; +} + .task-dialog-content { display: flex; max-height: calc(100dvh - 10rem); flex-direction: column; - gap: 0.5rem; + gap: 16px; overflow: auto; + padding: 0 24px 8px; + color: var(--ot-color-text); +} + +// Task abbreviation, name and current due date under the title. +.ext-context { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + margin-top: -4px; + font-size: 0.875rem; + line-height: 1.4; +} + +.ext-context__task { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.ext-context__name { + overflow-wrap: anywhere; + font-weight: 600; +} + +.ext-context__due { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--ot-color-text-muted); + white-space: nowrap; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + color: var(--ot-color-link); + } +} + +// Same chip as the extension request card in the task comments. +.ext-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--ot-radius-pill); + background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent); + color: var(--ot-color-text); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; +} + +.ext-chip--task { + font-weight: 600; +} + +.ext-callout { + @include hub.callout(raised); + @include hub.tinted-card(var(--ot-color-warning)); + margin: 0; + color: var(--ot-color-text); + + > mat-icon { + color: var(--ot-color-warning); + } +} + +.ext-description { + margin: 0; + color: var(--ot-color-text-muted); + line-height: 1.5; +} + +// The hint row sits inside the field, so no extra spacing is added below it. +.ext-field { + margin: 0; +} + +.ext-counter { + color: var(--ot-color-text-muted); + font-variant-numeric: tabular-nums; +} + +.ext-counter--warn { + color: var(--ot-color-warning); +} + +.ext-counter--limit { + color: var(--ot-color-error); + font-weight: 600; +} + +.ext-date { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.ext-summary { + font-variant-numeric: tabular-nums; +} + +.ext-date__error { + margin: 0; + color: var(--ot-color-error); + font-size: 0.75rem; } .task-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; - gap: 0.5rem; - padding: 0.75rem 1.5rem 1rem; + gap: 12px; + padding: 16px 24px 24px; button { min-height: 44px; + margin: 0; white-space: normal; } } +// The app paints every spinner circle in the divider colour, which vanishes on a +// disabled button. Inside the button it follows the label colour instead. +.ext-submit__spinner { + display: inline-block; + margin-right: 8px; + vertical-align: middle; + + ::ng-deep circle { + stroke: currentColor !important; + } +} + .task-dialog-error { margin: 0; border-left: 4px solid var(--ot-color-error); @@ -32,14 +165,20 @@ color: var(--ot-color-text); } -@media (max-width: 479.98px) { +@media (max-width: 599.98px) { .task-dialog-content { max-height: calc(100dvh - 11.5rem); - padding-inline: 1rem; + padding-inline: 16px; + } + + .ext-dialog-title { + padding-inline: 16px; } .task-dialog-actions { - padding-inline: 1rem; + flex-direction: column-reverse; + align-items: stretch; + padding-inline: 16px; button { width: 100%; diff --git a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts index d84cda3aa6..60c30525ed 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts @@ -1,17 +1,37 @@ -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {MatDialogRef} from '@angular/material/dialog'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {LOCALE_ID} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ReactiveFormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {provideNativeDateAdapter} from '@angular/material/core'; +import {MatDatepickerInputEvent, MatDatepickerModule} from '@angular/material/datepicker'; +import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; +import {MatInputModule} from '@angular/material/input'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; import {of, throwError} from 'rxjs'; import {Task, TaskCommentService} from 'src/app/api/models/doubtfire-model'; import {AlertService} from '../../services/alert.service'; import {ExtensionModalComponent} from './extension-modal.component'; -function buildComponent(requestExtension = vi.fn(() => of({}))) { +function buildTask( + dueDate = new Date('2026-09-01T00:00:00Z'), + deadlineDate = new Date('2026-10-01T00:00:00Z'), +): Task { + return { + definition: {abbreviation: '1.1P', name: 'Hello World'}, + localDueDate: () => dueDate, + localDeadlineDate: () => deadlineDate, + } as unknown as Task; +} + +function pickDate(component: ExtensionModalComponent, value: Date | null): void { + component.addEvent('input', {value} as MatDatepickerInputEvent); +} + +function buildComponent(requestExtension = vi.fn(() => of({})), task = buildTask()) { const close = vi.fn(); const afterApplication = vi.fn(); - const task = { - localDueDate: () => new Date('2026-09-01T00:00:00Z'), - localDeadlineDate: () => new Date('2026-10-01T00:00:00Z'), - } as Task; const alerts = {success: vi.fn(), error: vi.fn()}; const component = new ExtensionModalComponent( {close} as unknown as MatDialogRef, @@ -95,3 +115,168 @@ describe('ExtensionModalComponent', () => { vi.useRealTimers(); }); }); + +// Local dates, so the weekday labels hold in any time zone. 11 Sep 2026 is a Friday. +const DUE = new Date(2026, 8, 11, 23, 59); +const DEADLINE = new Date(2026, 9, 1, 23, 59); +const REASON = 'I was unwell for three days and missed the lab'; + +describe('ExtensionModalComponent presentation', () => { + beforeEach(() => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 5, 12, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('turns the counter to warning at 230 characters and error at the limit', () => { + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + const reason = component.extensionData.controls.extensionReason; + + reason.setValue('a'.repeat(229)); + expect(component.reasonCounterState).toBe('ok'); + reason.setValue('a'.repeat(230)); + expect(component.reasonCounterState).toBe('warn'); + reason.setValue('a'.repeat(256)); + expect(component.reasonCounterState).toBe('limit'); + }); + + it('summarises a picked date against the current due date in requested weeks', () => { + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + expect(component.extensionSummary).toBe(''); + + pickDate(component, new Date(2026, 8, 17)); + expect(component.extensionDays).toBe(6); + expect(component.extensionSummary).toBe('+1 week · Thu 17 Sep'); + + pickDate(component, new Date(2026, 8, 21)); + expect(component.extensionDays).toBe(10); + expect(component.extensionSummary).toBe('+2 weeks · Mon 21 Sep'); + }); + + it('shows the due date, the allowed range and how far past due the task is', () => { + const onTime = buildComponent(undefined, buildTask(DUE, DEADLINE)).component; + expect(onTime.formatShortDate(onTime.dueDate)).toBe('Fri 11 Sep'); + expect(onTime.dateRangeText).toBe('Sat 12 Sep to Thu 1 Oct'); + expect(onTime.daysPastDue).toBe(0); + + vi.setSystemTime(new Date(2026, 8, 19, 12, 0)); + const late = buildComponent(undefined, buildTask(DUE, DEADLINE)).component; + expect(late.daysPastDue).toBe(8); + }); + + it('keeps submit disabled until the reason and a date in range are both valid', () => { + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + expect(component.canSubmit).toBe(false); + component.extensionData.controls.extensionReason.setValue(REASON); + expect(component.canSubmit).toBe(false); + + pickDate(component, new Date(2026, 9, 20)); + expect(component.dateNeedsAttention).toBe(true); + expect(component.canSubmit).toBe(false); + + pickDate(component, null); + expect(component.canSubmit).toBe(false); + + pickDate(component, new Date(2026, 8, 17)); + expect(component.canSubmit).toBe(true); + + component.submitting = true; + expect(component.canSubmit).toBe(false); + }); +}); + +describe('ExtensionModalComponent template', () => { + let fixture: ComponentFixture; + let component: ExtensionModalComponent; + + beforeEach(async () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 19, 12, 0)); + + await TestBed.configureTestingModule({ + declarations: [ExtensionModalComponent], + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatDatepickerModule, + MatDialogModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], + providers: [ + provideNativeDateAdapter(), + {provide: MatDialogRef, useValue: {close: vi.fn()}}, + {provide: MAT_DIALOG_DATA, useValue: {task: buildTask(DUE, DEADLINE)}}, + {provide: LOCALE_ID, useValue: 'en-US'}, + {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}}, + {provide: TaskCommentService, useValue: {requestExtension: vi.fn(() => of({}))}}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExtensionModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const el = () => fixture.nativeElement as HTMLElement; + const submitButton = () => el().querySelector('button.ext-submit'); + + it('renders the title, task context, past due callout and new copy', () => { + expect(el().querySelector('[mat-dialog-title]')?.textContent?.trim()).toBe( + 'Request an extension', + ); + expect(el().querySelector('.ext-context')?.textContent).toContain('1.1P'); + expect(el().querySelector('.ext-context')?.textContent).toContain('Hello World'); + expect(el().querySelector('.ext-context__due')?.textContent?.trim()).toContain( + 'Due Fri 11 Sep', + ); + expect(el().querySelector('.ext-callout')?.textContent?.replace(/\s+/g, ' ').trim()).toContain( + 'This task is 8 days past its due date', + ); + expect(el().querySelector('.ext-description')?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + "Tell your teaching team why you need more time. They'll review your request and reply in the task comments.", + ); + }); + + it('labels the primary action without the date and enables it once valid', () => { + expect(submitButton()?.textContent?.trim()).toBe('send Request extension'); + expect(submitButton()?.disabled).toBe(true); + + component.extensionData.controls.extensionReason.setValue(REASON); + pickDate(component, new Date(2026, 8, 25)); + fixture.detectChanges(); + + expect(submitButton()?.disabled).toBe(false); + expect(el().querySelector('.ext-summary')?.textContent?.trim()).toBe('+2 weeks · Fri 25 Sep'); + }); + + it('marks the counter as a warning from 230 characters', () => { + const counter = () => el().querySelector('.ext-counter'); + component.extensionData.controls.extensionReason.setValue('a'.repeat(230)); + fixture.detectChanges(); + + expect(counter()?.textContent?.trim()).toBe('230 / 256'); + expect(counter()?.classList).toContain('ext-counter--warn'); + }); + + it('shows a spinner and disables both actions while submitting', () => { + component.submitting = true; + fixture.detectChanges(); + + expect(el().querySelector('.ext-submit__spinner')).not.toBeNull(); + const buttons = Array.from( + el().querySelectorAll('.task-dialog-actions button'), + ); + expect(buttons.every((button) => button.disabled)).toBe(true); + }); +}); diff --git a/src/app/common/modals/extension-modal/extension-modal.component.ts b/src/app/common/modals/extension-modal/extension-modal.component.ts index 40b6114183..72261dff59 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.ts @@ -1,4 +1,14 @@ -import {addDays, differenceInDays, differenceInWeeks, isAfter} from 'date-fns'; +import { + addDays, + differenceInCalendarDays, + differenceInDays, + differenceInWeeks, + endOfDay, + format, + isAfter, + isBefore, + startOfDay, +} from 'date-fns'; import {ChangeDetectionStrategy, Component, Inject, LOCALE_ID} from '@angular/core'; import {FormControl, FormGroup, FormGroupDirective, NgForm, Validators} from '@angular/forms'; import {ErrorStateMatcher} from '@angular/material/core'; @@ -25,10 +35,13 @@ export class ReasonErrorStateMatcher implements ErrorStateMatcher { export class ExtensionModalComponent { protected reasonMinLength: number = 15; protected reasonMaxLength: number = 256; + /** The counter turns to the warning colour from this many characters. */ + protected readonly reasonWarnLength = 230; submitting = false; errorMessage = ''; private allowClose = false; private dateChanged = false; + private dateEntryInvalid = false; constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: {task: Task; afterApplication?: () => void}, @@ -69,12 +82,85 @@ export class ExtensionModalComponent { maxDate = this.data.task.localDeadlineDate(); // deadline, hard deadline extensionDate = new Date(this.minDate); addEvent(type: string, event: MatDatepickerInputEvent) { + this.dateEntryInvalid = !event.value; if (event.value) { this.extensionDate = new Date(event.value); this.dateChanged = true; } } + /** Short date for the dialog, e.g. "Fri 11 Sep". */ + public formatShortDate(date: Date): string { + return format(date, 'EEE d MMM'); + } + + public get dueDate(): Date { + return this.data.task.localDueDate(); + } + + /** Whole days the task is past its due date, or 0 when it is not overdue. */ + public get daysPastDue(): number { + const diff = Date.now() - this.dueDate.getTime(); + return diff > 0 ? Math.ceil(diff / (1000 * 3600 * 24)) : 0; + } + + public get reasonLength(): number { + return this.extensionData.controls.extensionReason.value?.length ?? 0; + } + + public get reasonCounterState(): 'ok' | 'warn' | 'limit' { + if (this.reasonLength >= this.reasonMaxLength) { + return 'limit'; + } + return this.reasonLength >= this.reasonWarnLength ? 'warn' : 'ok'; + } + + /** The allowed range for the new due date, e.g. "Sat 12 Sep to Thu 1 Oct". */ + public get dateRangeText(): string { + if (!this.minDate || !this.maxDate) { + return ''; + } + return `${this.formatShortDate(this.minDate)} to ${this.formatShortDate(this.maxDate)}`; + } + + /** Calendar days between the current due date and the picked date. */ + public get extensionDays(): number { + return differenceInCalendarDays(this.extensionDate, this.dueDate); + } + + public get isDateInRange(): boolean { + if (this.minDate && isBefore(this.extensionDate, startOfDay(this.minDate))) { + return false; + } + if (this.maxDate && isAfter(this.extensionDate, endOfDay(this.maxDate))) { + return false; + } + return true; + } + + /** True once the date field holds something that cannot be requested. */ + public get dateNeedsAttention(): boolean { + return this.dateEntryInvalid || (this.dateChanged && !this.isDateInRange); + } + + /** + * The picked extension as it will be requested, e.g. "+1 week · Thu 17 Sep". + * Requests are made in whole weeks, so the length is shown in weeks. + */ + public get extensionSummary(): string { + if (!this.dateChanged || this.dateNeedsAttention) { + return ''; + } + const weeks = this.extensionDuration; + return `+${weeks} ${weeks === 1 ? 'week' : 'weeks'} · ${this.formatShortDate(this.extensionDate)}`; + } + + public get canSubmit(): boolean { + return ( + this.extensionData.valid && this.dateChanged && !this.dateNeedsAttention && !this.submitting + ); + } + public get isDirty(): boolean { return this.extensionData.dirty || this.dateChanged; } diff --git a/src/app/common/modals/extension-modal/extension-modal.service.spec.ts b/src/app/common/modals/extension-modal/extension-modal.service.spec.ts index 82a12ab162..fa4d4f1a49 100644 --- a/src/app/common/modals/extension-modal/extension-modal.service.spec.ts +++ b/src/app/common/modals/extension-modal/extension-modal.service.spec.ts @@ -17,7 +17,7 @@ describe('ExtensionModalService', () => { autoFocus: 'dialog', closeOnNavigation: true, maxHeight: 'calc(100dvh - 2rem)', - maxWidth: '700px', + maxWidth: '560px', restoreFocus: true, width: 'calc(100vw - 2rem)', }); diff --git a/src/app/common/modals/extension-modal/extension-modal.service.ts b/src/app/common/modals/extension-modal/extension-modal.service.ts index 497acf9e5b..73c51be7a4 100644 --- a/src/app/common/modals/extension-modal/extension-modal.service.ts +++ b/src/app/common/modals/extension-modal/extension-modal.service.ts @@ -16,7 +16,7 @@ export class ExtensionModalService { autoFocus: 'dialog', closeOnNavigation: true, maxHeight: 'calc(100dvh - 2rem)', - maxWidth: '700px', + maxWidth: '560px', panelClass: 'responsive-task-dialog', restoreFocus: true, width: 'calc(100vw - 2rem)', From 26b373b85102454619eeecef620d88daf34327be Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:55:12 +1000 Subject: [PATCH 43/88] fix(feedback-review): tidy the request feedback review dialog Show the task under the title, shorten the description and move the review allowance and the final decision note into one info callout. The reason field has a label, grows with its text and its counter warns near the limit. Actions use the shared filled and outlined buttons with a spinner while sending, and stack on phones. --- .../feedback-appeal-modal.component.html | 77 +++++++---- .../feedback-appeal-modal.component.scss | 128 +++++++++++++++++- .../feedback-appeal-modal.component.spec.ts | 112 ++++++++++++++- .../feedback-appeal-modal.component.ts | 15 ++ .../feedback-appeal-modal.service.ts | 2 +- 5 files changed, 298 insertions(+), 36 deletions(-) diff --git a/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html index e2d8a16a8e..eed1c80b13 100644 --- a/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html +++ b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html @@ -1,52 +1,77 @@ -

Request feedback review

+

Request a feedback review

-

} diff --git a/src/app/common/additional-notification-email/additional-notification-email.component.scss b/src/app/common/additional-notification-email/additional-notification-email.component.scss index ca48833231..c944a90577 100644 --- a/src/app/common/additional-notification-email/additional-notification-email.component.scss +++ b/src/app/common/additional-notification-email/additional-notification-email.component.scss @@ -1,3 +1,7 @@ +@use '../edit-profile-form/profile-form' as profile; + +@include profile.theme; + :host { display: block; min-width: 0; @@ -5,46 +9,33 @@ } .additional-email { - border: 1px solid var(--ot-color-border); - border-radius: 12px; - display: grid; - gap: 12px; - margin-block: 16px; - padding: 16px; - width: 100%; - min-width: 0; - overflow-wrap: anywhere; + @include profile.card; + + // empty live regions stay in the tree for screen readers but take no gap + > p:empty { + margin-top: -16px; + } &__heading { - align-items: flex-start; display: flex; - gap: 12px; + align-items: flex-start; justify-content: space-between; + gap: 12px; > div { - min-width: 0; - } - - h3, - p { - margin: 0; - } - - p { - color: var(--ot-color-text-muted); - margin-top: 4px; - max-width: 42rem; + @include profile.card-heading; } } &__status { + flex: 0 0 auto; + padding: 4px 10px; + border-radius: var(--ot-radius-pill); background: var(--ot-color-warning); - border-radius: 999px; color: var(--ot-color-on-warning); - flex: 0 0 auto; - font-size: 0.8125rem; + font-size: 0.78rem; font-weight: 600; - padding: 5px 10px; + line-height: 1.4; &--verified { background: var(--ot-status-complete); @@ -70,6 +61,8 @@ &__message, &__error { margin: 0; + font-size: 0.875rem; + line-height: 1.45; } &__help { @@ -86,13 +79,11 @@ } } -@media (max-width: 420px) { +@media (max-width: 599px) { .additional-email { - padding: 14px; - &__heading { - align-items: stretch; flex-direction: column; + align-items: stretch; } &__status { diff --git a/src/app/common/edit-profile-form/_profile-form.scss b/src/app/common/edit-profile-form/_profile-form.scss new file mode 100644 index 0000000000..e94196bf6d --- /dev/null +++ b/src/app/common/edit-profile-form/_profile-form.scss @@ -0,0 +1,193 @@ +// Shared look for the profile page and the settings cards it hosts (additional +// notification email, notification settings). Cards follow the Unit Hub card, fields +// run at 48px like the other redesigned forms, and every colour is an --ot-* token. +@use '../../unit-hub/hub-card' as hub; + +// Include once at the top level of a component stylesheet. +@mixin theme { + @include hub.card-theme; + + :host { + // fields: 48px (Material density -2), as in the groups and units toolbars + --mat-form-field-container-height: 48px; + --mat-form-field-container-vertical-padding: 12px; + --mat-form-field-subscript-text-size: 0.78rem; + --mat-form-field-subscript-text-line-height: 1.4; + --mat-form-field-error-text-color: var(--ot-color-error); + --mat-form-field-outlined-input-text-color: var(--ot-color-text); + --mat-form-field-outlined-label-text-color: var(--ot-color-text-muted); + --mat-form-field-outlined-hover-label-text-color: var(--ot-color-text-muted); + --mat-form-field-outlined-focus-outline-color: var(--ot-color-primary); + --mat-form-field-outlined-focus-label-text-color: var(--ot-color-link); + --mat-form-field-outlined-hover-outline-color: var(--ot-color-text-muted); + --mat-form-field-outlined-container-shape: var(--ot-radius-sm); + + // buttons: the app's filled or outlined style, at a 44px touch height + --mat-button-filled-container-height: 44px; + --mat-button-outlined-container-height: 44px; + } + + // Selection controls take the primary token. Set on the controls themselves so a + // colour class from the theme cannot re-point them. + :host ::ng-deep .mat-mdc-checkbox.mat-mdc-checkbox { + --mat-checkbox-selected-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-hover-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-focus-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-pressed-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-checkmark-color: var(--ot-color-on-primary); + --mat-checkbox-selected-hover-state-layer-color: var(--ot-color-primary); + --mat-checkbox-selected-focus-state-layer-color: var(--ot-color-primary); + --mat-checkbox-selected-pressed-state-layer-color: var(--ot-color-primary); + --mat-checkbox-label-text-color: var(--ot-color-text); + } + + :host ::ng-deep .mat-mdc-slide-toggle.mat-mdc-slide-toggle { + --mat-slide-toggle-selected-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-hover-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-focus-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-pressed-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-hover-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-focus-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-pressed-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-icon-color: var(--ot-color-on-primary); + --mat-slide-toggle-label-text-color: var(--ot-color-text); + } + + :host ::ng-deep .mat-mdc-form-field-hint { + color: var(--ot-color-text-muted); + } + + :host ::ng-deep .mat-mdc-form-field-error { + color: var(--ot-color-error); + } +} + +// One card: surface token, soft token edge, 20px padding (16px on phones). +@mixin card { + @include hub.card(page); + display: flex; + flex-direction: column; + gap: 16px; + box-sizing: border-box; + width: 100%; + min-width: 0; + margin: 0; + padding: 20px; + color: var(--ot-color-text); + overflow-wrap: anywhere; + + @media (max-width: 599px) { + padding: 16px; + } +} + +// Card heading: a small link-coloured icon, the title, and a muted description. +@mixin card-heading { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + + h2 { + @include hub.icon-heading; + color: var(--ot-color-text); + } + + p { + margin: 0; + max-width: 42rem; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + line-height: 1.5; + } +} + +// Two field columns on wide screens, one on phones. +@mixin field-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + min-width: 0; + + &:empty { + display: none; + } + + mat-form-field { + width: 100%; + min-width: 0; + } + + @media (max-width: 599px) { + grid-template-columns: minmax(0, 1fr); + } +} + +// A setting row: label and description on the left, the control on the right. The +// control is a checkbox or toggle with labelPosition="before", stretched to the row. +@mixin setting-row { + display: grid; + gap: 2px; + min-width: 0; + + > :is(mat-checkbox, mat-slide-toggle) { + display: block; + width: 100%; + } + + // the checkbox box carries 11px of touch padding; let it overhang so its edge lines + // up with a toggle's + > mat-checkbox { + width: calc(100% + 11px); + margin-inline-end: -11px; + } + + > :is(mat-checkbox, mat-slide-toggle) ::ng-deep .mdc-form-field { + display: flex; + justify-content: space-between; + gap: 12px; + width: 100%; + } + + > :is(mat-checkbox, mat-slide-toggle) ::ng-deep label { + // Material pushes a "before" label against the control with an auto margin; + // let it fill the row instead so the control sits at the far edge + flex: 1 1 auto; + margin: 0; + padding-inline: 0; + text-align: start; + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 600; + line-height: 1.4; + } + + > small { + display: block; + // the control's 40px touch target sets the row height; pull the text up to the label + margin-top: -6px; + // clear the control on the right + padding-inline-end: 56px; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.45; + } +} diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.html b/src/app/common/edit-profile-form/edit-profile-form.component.html index d9f2ea812a..08cbf759ff 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.html +++ b/src/app/common/edit-profile-form/edit-profile-form.component.html @@ -1,77 +1,131 @@
-
-
- -
-
- - +
+
+ + + -
-

{{ user?.firstName }}

- Set up your {{ externalName.value }} profile -
-
+
+

{{ displayName }}

+ @if (mode === 'create') { +

Set up your {{ externalName.value }} profile

+ } + @if (user?.username || user?.email) { +

+ @if (user?.username) { + {{ user.username }} + } + @if (user?.email) { + {{ user.email }} + } +

+ }
+ - @if (newUser) { -
- - Username - - -
- } @else { - + +
+
+

Your name

+
+ +
+ + First name Account information

} - - Second Name + + Last name Account information [(ngModel)]="user.lastName" /> @if (lastModel.hasError('required')) { - Second name is required + Last name is required } -
-
- - Preferred Name + + Preferred name Account information [(ngModel)]="user.nickname" /> + + + Custom pronouns + +
+ - - Custom Pronouns - - + @if (canEditEmail) { +
+
+

Contact

+
- @if (user.systemRole === 'Student') { - @if (canEditStudentId) { - - Student ID +
+ + Email - @if (studentIdModel.hasError('required')) { - Student ID is required + @if (emailModel.hasError('required')) { + Email is required + } + @if (emailModel.hasError('email')) { + Enter a valid email address } - } - } - - @if (canEditEmail) { - - Email - - @if (emailModel.hasError('required')) { - Email is required - } - @if (emailModel.hasError('email')) { - Enter a valid email address - } - - } - - @if (canSeeSystemRole) { - - System Role - - Administrator - Convenor - Tutor - Student - Auditor - - - } +
+
+ } - @if (mode === 'edit' && managingOwnProfile) { - - } + @if (mode === 'edit' && managingOwnProfile) { + + } - + -
-

Peer progress

- - Display anonymous peer progress statistics - - - This controls whether you see the privacy-protected widget below your submission details. - It does not opt your anonymous task status out of authorised cohort aggregation. - -
- - -
+ +
+
+

+ Push notifications +

+
+
-
- +
+ +
+
+

+ Peer progress +

+
+
+ + Display anonymous peer progress statistics + + + This controls whether you see the privacy-protected widget below your submission details. + It does not opt your anonymous task status out of authorised cohort aggregation. + +
+
+ +
+
+ +
+
+ Send anonymous research statistics -
+ @if (tiiEnabled) { -
+
Accepted TurnItIn EULA -
+ + } +
+ + +
+
+ @if (form.invalid && form.dirty) { +

Fix the highlighted fields before saving.

} -
+

+ {{ saveMessage }} +

+

{{ saveError }}

+
+ + + @if (mode === 'create') { + + } + @if (mode === 'edit') { + @if (form.dirty || saving) { -
- @if (mode === 'create') { - - } - @if (mode === 'edit') { - - } -
-
- @if (form.invalid && form.dirty) { -

Fix the highlighted fields before saving.

- } -

{{ saveMessage }}

-

{{ saveError }}

-
-
+ } @else { +

+ + All changes saved +

+ } + } diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.scss b/src/app/common/edit-profile-form/edit-profile-form.component.scss index 01a748bf22..aa8f9a016b 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.scss +++ b/src/app/common/edit-profile-form/edit-profile-form.component.scss @@ -1,76 +1,120 @@ +// Profile page, first-login form and the admin Users dialog. Cards stack with one +// 16px rhythm and no rules between sections. Colours come only from --ot-* tokens. +@use './profile-form' as profile; + +@include profile.theme; + :host { display: block; height: auto; + min-width: 0; + // the save bar's height, so a control focused from the keyboard scrolls clear of it + --profile-actions-height: 68px; } -form, -.main-container, -.main { +.profile-form { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; max-width: 100%; + min-width: 0; + padding-inline: clamp(16px, 4vw, 28px); + color: var(--ot-color-text); } -.main { - gap: 4px; - padding: 16px clamp(16px, 4vw, 28px) calc(3rem + env(safe-area-inset-bottom)); +.profile-form--modal { + padding: 24px 24px 0; + + @media (max-width: 599px) { + padding: 16px 16px 0; + } } +.profile-stack { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + padding-block: 16px; -h1 { - // THM-M01: was hardcoded black. - color: var(--ot-color-text, #000); - margin-top: 0; + :is(input, textarea, button, a, mat-select, mat-checkbox, mat-slide-toggle) { + scroll-margin-block: 16px calc(var(--profile-actions-height) + 16px); + } } -a { - :hover { - text-decoration: none; - opacity: 0.6; - } +.profile-card { + @include profile.card; } -p { - color: var(--ot-color-text, #000); +.profile-card__heading { + @include profile.card-heading; } -.form-field { - margin: 5px; +.profile-fields { + @include profile.field-grid; } -section { - padding: 12px; +.profile-fields__wide { + grid-column: 1 / -1; } -.account-information { - background: var(--ot-color-surface); - color: var(--ot-color-text); - border: 1px solid var(--ot-color-border); - border-radius: 12px; - display: grid; - gap: 12px; - margin-bottom: 16px; - padding: 16px; - width: 100%; +// ---- Header ---- +.profile-header { + flex-direction: row; + align-items: center; + gap: 16px; +} - h2, - p, - dl, - dd { - margin: 0; +.profile-header__avatar { + display: block; + flex-shrink: 0; + width: 64px; + height: 64px; + border-radius: var(--ot-radius-circle); + transition: opacity 160ms ease; + + &:hover { + opacity: 0.8; } +} + +.profile-header__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; - h2 { - font-size: 1.125rem; + h1 { + margin: 0; + color: var(--ot-color-text); + font-size: 1.5rem; + font-weight: 650; + line-height: 1.3; + letter-spacing: -0.01em; } p { + margin: 0; color: var(--ot-color-text-muted); - margin-top: 4px; + font-size: 0.9rem; + line-height: 1.45; } +} +.profile-header__meta { + display: flex; + flex-wrap: wrap; + gap: 0 12px; +} + +// ---- Account facts ---- +.account-information { dl { display: grid; - gap: 10px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px 24px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + margin: 0; } dl > div { @@ -78,91 +122,115 @@ section { } dt { + margin: 0 0 4px; color: var(--ot-color-text-muted); - font-size: 0.8125rem; + font-size: 0.72rem; font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.4; + text-transform: uppercase; } dd { + margin: 0; + color: var(--ot-color-text); + font-size: 0.95rem; overflow-wrap: anywhere; } - - &__managed { - border-top: 1px solid var(--ot-color-divider); - padding-top: 10px; - } } -/* checkbox */ -::ng-deep .mdc-form-field label { - font-weight: normal; +.account-information__managed { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; } -.mat-icon { - width: 50px; - height: 50px; - font-size: 50px; +// ---- Setting rows ---- +.profile-setting { + @include profile.setting-row; } -.push-opt-in { - // The rule above sizes the profile avatar icon at 50px. Left alone it would - // apply to the icon inside this button too and blow the button apart. - .mat-icon { - width: 20px; - height: 20px; - font-size: 20px; - margin-right: 6px; - } +.push-opt-in__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + min-width: 0; - &__hint { - display: block; - margin-top: 6px; - opacity: 0.7; + button { + max-width: 100%; + height: auto; + min-height: 44px; + white-space: normal; } } -.peer-progress-preference { - // Divider token equals the raised surface in dark (#353c47), so this separator - // vanishes on the dialog. Use the border token in dark where it actually reads; - // keep the softer divider in light. - border-block: 1px solid var(--ot-color-divider); - :root[data-ot-theme='dark'] & { - border-block-color: var(--ot-color-border); - } - gap: 6px; - margin-block: 8px; - padding-block: 16px; - - h3 { - margin: 0; - } +.push-opt-in__hint { + display: block; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.45; +} - small { - color: var(--ot-color-text-muted); - max-width: 42rem; - } +.push-opt-in__instructions { + margin: 0; + padding-inline-start: 1.25rem; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.5; } +// ---- Save bar ---- +// Sticky at the foot of the content column. It sits in flow after the last card, so +// at the end of the page it never covers anything. .profile-actions { - background: var(--ot-color-surface-raised); - border-top: 1px solid var(--ot-color-divider); - bottom: 0; - padding: 12px clamp(16px, 4vw, 28px) max(12px, env(safe-area-inset-bottom)); position: sticky; - width: 100%; + bottom: 0; z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px 12px; + box-sizing: border-box; + min-height: var(--profile-actions-height); + padding: 12px 20px max(12px, env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--hub-card-border); + background: var(--ot-color-surface); - button { - min-height: 44px; + @media (max-width: 599px) { + padding-inline: 16px; + + > button { + flex: 1 1 100%; + } + } +} + +.profile-actions__saved { + display: flex; + align-items: center; + gap: 6px; + min-height: 44px; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; } } .profile-save-status { - min-height: 24px; - width: 100%; + flex: 1 1 12rem; + min-width: 0; + font-size: 0.875rem; + line-height: 1.45; p { - margin: 4px 0 0; + margin: 0; } &__success { @@ -175,34 +243,3 @@ section { font-weight: 600; } } - -.profile-name-fields { - display: grid; - gap: 16px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - width: 100%; - - mat-form-field { - min-width: 0; - width: 100%; - } -} - -@media (max-width: 600px) { - .profile-name-fields { - gap: 0; - grid-template-columns: 1fr; - } - - .main { - padding-inline: 16px; - } - - .account-information dl { - grid-template-columns: 1fr; - } - - .profile-actions { - padding-inline: 16px; - } -} diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts index cfd2dce546..8d0d7a0a1e 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts @@ -407,3 +407,82 @@ describe('EditProfileFormComponent notifications page link', () => { expect(notificationsLink()).toBeNull(); }); }); + +// The save bar swaps its action for a quiet status once nothing is left to save. +@Directive({selector: 'form', exportAs: 'ngForm', standalone: false}) +class StubNgFormSaveBar { + public invalid = false; + public dirty = false; + public pristine = true; +} + +describe('EditProfileFormComponent save bar and labels', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + const currentUser = makeUser({firstName: 'Ada', lastName: 'Lovelace', username: 'ada'}); + + await TestBed.configureTestingModule({ + declarations: [EditProfileFormComponent, StubNgFormSaveBar, StubNotificationNgModel], + providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, + { + provide: DoubtfireConstants, + useValue: {ExternalName: {value: 'OnTrack'}, IsTiiEnabled: {value: false}}, + }, + {provide: UserService, useValue: {currentUser}}, + {provide: Router, useValue: {}}, + {provide: AuthenticationService, useValue: {}}, + {provide: MAT_DIALOG_DATA, useValue: null}, + {provide: MatSnackBar, useValue: {}}, + {provide: PushNotificationService, useValue: pushServiceStub}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(EditProfileFormComponent); + fixture.componentRef.setInput('mode', 'edit'); + fixture.detectChanges(); + }); + + const form = (): StubNgFormSaveBar => + fixture.debugElement.children[0].injector.get(StubNgFormSaveBar); + const text = (): string => fixture.nativeElement.textContent; + + it('shows a quiet saved status instead of a disabled button when clean', () => { + expect(fixture.nativeElement.querySelector('button[type="submit"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('.profile-actions__saved')?.textContent).toContain( + 'All changes saved', + ); + }); + + it('shows the save action once the form has changes', () => { + form().dirty = true; + form().pristine = false; + fixture.detectChanges(); + + const submit: HTMLButtonElement = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(submit.textContent).toContain('Save changes'); + expect(fixture.nativeElement.querySelector('.profile-actions__saved')).toBeNull(); + }); + + it('labels the name fields in sentence case', () => { + const labels = Array.from( + fixture.nativeElement.querySelectorAll( + '.profile-name-fields mat-label', + ) as NodeListOf, + ).map((label) => label.textContent.trim()); + + expect(labels).toEqual(['First name', 'Last name', 'Preferred name', 'Custom pronouns']); + expect(text()).not.toContain('Second Name'); + }); + + it('puts the display name and username in the header', () => { + expect(fixture.nativeElement.querySelector('.profile-header h1').textContent.trim()).toBe( + 'Ada Lovelace', + ); + expect(fixture.nativeElement.querySelector('.profile-header__meta').textContent).toContain( + 'ada', + ); + }); +}); diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.ts b/src/app/common/edit-profile-form/edit-profile-form.component.ts index acea8b0136..ed2c2be306 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.ts @@ -186,6 +186,13 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { this.authService.signOut(); } + /** The name shown in the profile header: preferred or first name, then last name. */ + public get displayName(): string { + const first = this.user?.nickname?.trim() || this.user?.firstName?.trim() || ''; + const name = [first, this.user?.lastName?.trim()].filter(Boolean).join(' '); + return name || this.user?.username || ''; + } + public get newUser(): boolean { return this.mode === 'new'; } diff --git a/src/app/common/notification-settings/notification-settings.component.html b/src/app/common/notification-settings/notification-settings.component.html index eb1b62c3b0..f036d9d07c 100644 --- a/src/app/common/notification-settings/notification-settings.component.html +++ b/src/app/common/notification-settings/notification-settings.component.html @@ -1,26 +1,31 @@
-

Notification Settings

+
+

+ Notifications +

-

- Choose which notification categories you want to receive. -

- - @if (showNotificationsLink) { -

- You can view the notifications controlled by these preferences on the - Notifications page. +

+ Choose which notification categories you want to receive.

- } + + @if (showNotificationsLink) { +

+ You can view the notifications controlled by these preferences on the + Notifications page. +

+ } +
@@ -36,6 +41,7 @@

Notification Settings

@@ -50,6 +56,7 @@

Notification Settings

@@ -64,6 +71,7 @@

Notification Settings

diff --git a/src/app/common/notification-settings/notification-settings.component.scss b/src/app/common/notification-settings/notification-settings.component.scss index cd80dd827f..9da1282b83 100644 --- a/src/app/common/notification-settings/notification-settings.component.scss +++ b/src/app/common/notification-settings/notification-settings.component.scss @@ -1,3 +1,7 @@ +@use '../edit-profile-form/profile-form' as profile; + +@include profile.theme; + :host { display: block; min-width: 0; @@ -5,56 +9,35 @@ } .notification-settings { - border: 1px solid var(--ot-color-border); - border-radius: 12px; - gap: 10px; - margin-block: 16px; - padding: 16px; - width: 100%; + @include profile.card; + gap: 20px; } -.notification-settings h3 { - color: var(--ot-color-text); - margin: 0; -} +.notification-settings__heading { + @include profile.card-heading; -.notification-settings__intro { - color: var(--ot-color-text-muted); - margin: 0; + a { + color: var(--ot-color-link); + text-decoration: underline; + text-underline-offset: 3px; + } } +// label and description on the left, the checkbox on the right; one gap between +// rows instead of a box around each .notification-setting { - border: 1px solid var(--ot-color-divider); - border-radius: 10px; - display: grid; - gap: 2px; - min-height: 64px; - padding: 8px 10px; -} - -.notification-setting mat-checkbox { - width: 100%; + @include profile.setting-row; } .notification-setting__channels { display: flex; flex-wrap: wrap; gap: 0 16px; - padding-inline-start: 32px; + margin-block: 4px 2px; + // line the boxes up with the text above, past the checkbox's touch padding + margin-inline-start: -11px; } .notification-setting__channels mat-checkbox { width: auto; } - -.notification-setting__help { - color: var(--ot-color-text-muted); - line-height: 1.4; - padding: 0 8px 4px 40px; -} - -@media (max-width: 420px) { - .notification-settings { - padding: 14px; - } -} diff --git a/src/app/common/notification-settings/notification-settings.component.spec.ts b/src/app/common/notification-settings/notification-settings.component.spec.ts index c805505189..45e9868284 100644 --- a/src/app/common/notification-settings/notification-settings.component.spec.ts +++ b/src/app/common/notification-settings/notification-settings.component.spec.ts @@ -4,6 +4,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatCheckboxHarness} from '@angular/material/checkbox/testing'; +import {MatIconModule} from '@angular/material/icon'; import {RouterModule} from '@angular/router'; import {User} from 'src/app/api/models/user/user'; import {NotificationSettingsComponent} from './notification-settings.component'; @@ -28,7 +29,7 @@ describe('NotificationSettingsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NotificationSettingsComponent], - imports: [FormsModule, MatCheckboxModule, RouterModule.forRoot([])], + imports: [FormsModule, MatCheckboxModule, MatIconModule, RouterModule.forRoot([])], }).compileComponents(); fixture = TestBed.createComponent(NotificationSettingsComponent); From eab667f9bbc6ef6e41dbbc858b57b969a9236ee1 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:59:05 +1000 Subject: [PATCH 45/88] fix(extension): keep the request possible after the final deadline When the final deadline has passed there is no date left to pick, so the dialog requests the earliest date as before and says so. Past due days round down to match the banner on the task page. --- .../extension-modal.component.html | 2 ++ .../extension-modal.component.spec.ts | 16 +++++++++-- .../extension-modal.component.ts | 27 +++++++++++++++---- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/app/common/modals/extension-modal/extension-modal.component.html b/src/app/common/modals/extension-modal/extension-modal.component.html index cf49e4db3c..2390d2d0ef 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.html +++ b/src/app/common/modals/extension-modal/extension-modal.component.html @@ -73,6 +73,8 @@

Request an extension

@if (dateRangeText) { Choose a date from {{ dateRangeText }} + } @else if (!hasDateRange) { + The final deadline has passed, so the earliest date is requested } diff --git a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts index 60c30525ed..cf04d609d9 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts @@ -163,11 +163,23 @@ describe('ExtensionModalComponent presentation', () => { expect(onTime.dateRangeText).toBe('Sat 12 Sep to Thu 1 Oct'); expect(onTime.daysPastDue).toBe(0); - vi.setSystemTime(new Date(2026, 8, 19, 12, 0)); + vi.setSystemTime(new Date(2026, 8, 20, 12, 0)); const late = buildComponent(undefined, buildTask(DUE, DEADLINE)).component; expect(late.daysPastDue).toBe(8); }); + it('requests the earliest date without a pick once the final deadline has passed', () => { + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + expect(component.hasDateRange).toBe(false); + expect(component.dateRangeText).toBe(''); + expect(component.extensionSummary).toBe('+4 weeks · Sun 4 Oct'); + + component.extensionData.controls.extensionReason.setValue(REASON); + expect(component.canSubmit).toBe(true); + }); + it('keeps submit disabled until the reason and a date in range are both valid', () => { const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); @@ -196,7 +208,7 @@ describe('ExtensionModalComponent template', () => { beforeEach(async () => { vi.useFakeTimers({toFake: ['Date']}); - vi.setSystemTime(new Date(2026, 8, 19, 12, 0)); + vi.setSystemTime(new Date(2026, 8, 20, 12, 0)); await TestBed.configureTestingModule({ declarations: [ExtensionModalComponent], diff --git a/src/app/common/modals/extension-modal/extension-modal.component.ts b/src/app/common/modals/extension-modal/extension-modal.component.ts index 72261dff59..1b4ee6ae49 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.ts @@ -98,10 +98,13 @@ export class ExtensionModalComponent { return this.data.task.localDueDate(); } - /** Whole days the task is past its due date, or 0 when it is not overdue. */ + /** + * Whole days the task is past its due date, or 0 when it is not overdue. Rounds down, + * like the "Passed Due Date By" banner on the task page. + */ public get daysPastDue(): number { const diff = Date.now() - this.dueDate.getTime(); - return diff > 0 ? Math.ceil(diff / (1000 * 3600 * 24)) : 0; + return diff > 0 ? Math.floor(diff / (1000 * 3600 * 24)) : 0; } public get reasonLength(): number { @@ -115,9 +118,17 @@ export class ExtensionModalComponent { return this.reasonLength >= this.reasonWarnLength ? 'warn' : 'ok'; } + /** + * False once the final deadline has passed, when the earliest date is after the latest. + * The request then goes out for the earliest date, as it always has. + */ + public get hasDateRange(): boolean { + return !this.maxDate || !isAfter(startOfDay(this.minDate), this.maxDate); + } + /** The allowed range for the new due date, e.g. "Sat 12 Sep to Thu 1 Oct". */ public get dateRangeText(): string { - if (!this.minDate || !this.maxDate) { + if (!this.minDate || !this.maxDate || !this.hasDateRange) { return ''; } return `${this.formatShortDate(this.minDate)} to ${this.formatShortDate(this.maxDate)}`; @@ -129,6 +140,9 @@ export class ExtensionModalComponent { } public get isDateInRange(): boolean { + if (!this.hasDateRange) { + return true; + } if (this.minDate && isBefore(this.extensionDate, startOfDay(this.minDate))) { return false; } @@ -148,7 +162,7 @@ export class ExtensionModalComponent { * Requests are made in whole weeks, so the length is shown in weeks. */ public get extensionSummary(): string { - if (!this.dateChanged || this.dateNeedsAttention) { + if ((!this.dateChanged && this.hasDateRange) || this.dateNeedsAttention) { return ''; } const weeks = this.extensionDuration; @@ -157,7 +171,10 @@ export class ExtensionModalComponent { public get canSubmit(): boolean { return ( - this.extensionData.valid && this.dateChanged && !this.dateNeedsAttention && !this.submitting + this.extensionData.valid && + (this.dateChanged || !this.hasDateRange) && + !this.dateNeedsAttention && + !this.submitting ); } From 3113f968b7f3b9b68102bc78f44bb78f2b89bc4d Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 00:59:07 +1000 Subject: [PATCH 46/88] style(submit-task): calmer submit dialog with requirements callout, step indicator and file rows Title reads Submit task with the abbreviation chip, name and due date underneath. Submit as is a compact status picker on its own row. The dialog drops its nested cards, the requirements become a What to upload callout, each drop zone is a rounded dashed zone with a drag-over state, and the file list shows a status chip per file. Footer has an outlined Cancel and a filled Next or Submit with a hint while it is disabled. Behaviour, inputs and validation are unchanged. --- .../file-uploader.component.html | 135 ++++-- .../file-uploader.component.scss | 271 +++++++++-- .../file-uploader.component.spec.ts | 3 +- .../file-uploader/file-uploader.component.ts | 46 +- .../task-upload-requirements.component.html | 119 +++-- .../task-upload-requirements.component.scss | 127 +++-- ...task-upload-requirements.component.spec.ts | 38 +- .../task-upload-requirements.component.ts | 19 + .../upload-category.ts | 15 + .../upload-submission-modal.component.html | 446 ++++++++++-------- ...ubmission-modal.component.reviewed.spec.ts | 2 +- .../upload-submission-modal.component.scss | 396 ++++++++++++++-- .../upload-submission-modal.component.spec.ts | 23 + .../upload-submission-modal.component.ts | 88 +++- 14 files changed, 1309 insertions(+), 419 deletions(-) diff --git a/src/app/common/file-uploader/file-uploader.component.html b/src/app/common/file-uploader/file-uploader.component.html index 226e074463..0ce2a315dc 100644 --- a/src/app/common/file-uploader/file-uploader.component.html +++ b/src/app/common/file-uploader/file-uploader.component.html @@ -1,5 +1,5 @@ - - +
+
@if (!showUploader) { } -
+
@if (showUploader && uploadingInfo === null && shownUploadZones.length) { -
+
@for (upload of shownUploadZones; track upload) { @if (!singleDropZone && showName) { -
+
{{ uploadZones.length === 1 ? '' : $index + 1 + ' - ' }} {{ upload.display.name }}
} @if (singleDropZone && showName) { -
Select {{ upload.display.name }}
+
{{ upload.display.name }}
}
@@ -67,9 +73,18 @@
Select {{ upload.display.name }}
@if (!singleDropZone && upload.model?.length > 0) { -
- - {{ upload.model[0].name }} +
+ + + {{ upload.model[0].name }} + {{ formatSize(upload.model[0].size) }} + +
  • + + + {{ upload.model[0].name }} + {{ upload.display.name }} · + {{ formatSize(upload.model[0].size) }} + + Ready + +
  • } @else { - File pending +
  • + + + {{ upload.display.name }} + {{ acceptedFormatsLabel(upload) }} + + @if (upload.display.error) { + Wrong type + } @else { + Waiting for file + } +
  • } -
    - } + } +
    }
    @@ -186,5 +225,5 @@
    Upload Summary
    } } - - +
    +
    diff --git a/src/app/common/file-uploader/file-uploader.component.scss b/src/app/common/file-uploader/file-uploader.component.scss index 1e45ae42da..78eee3433f 100644 --- a/src/app/common/file-uploader/file-uploader.component.scss +++ b/src/app/common/file-uploader/file-uploader.component.scss @@ -1,4 +1,3 @@ -.file-drop-zone .mat-icon, .complete .mat-icon { font-size: 50px; height: 50px; @@ -11,82 +10,268 @@ width: 75px; } +.file-input { + display: none; +} + +.file-uploader-card, +.file-uploader-content { + box-sizing: border-box; + min-width: 0; + width: 100%; +} + +// Sized by the space the uploader gets, not the viewport, so a wide dialog puts the +// drop zone and the file list side by side and a narrow one stacks them. +.file-uploader-content { + container-type: inline-size; +} + +.file-uploader-layout { + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1fr); + min-width: 0; +} + +@container (min-width: 600px) { + .file-uploader-layout--split { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + > :only-child { + grid-column: 1 / -1; + } + } +} + +.file-uploader-zones, +.file-uploader-summary { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +// A normal section label above each zone and above the file list. +.file-uploader-label { + margin: 0; + color: var(--ot-color-text); + font-size: 0.9rem; + font-weight: 600; + line-height: 1.4; +} + +// --- Drop zone --- + .file-drop-zone { align-items: center; - background: transparent; - border: 2px dashed var(--ot-color-border); - border-radius: var(--ot-radius-sm); - color: inherit; + background: var(--ot-color-surface); + border: 1.5px dashed var(--ot-color-border); + border-radius: var(--ot-radius-lg); + box-sizing: border-box; + color: var(--ot-color-text); cursor: pointer; display: flex; flex-direction: column; font: inherit; + gap: 4px; justify-content: center; - padding: 2.5rem; + min-height: 180px; + padding: 20px 16px; text-align: center; + width: 100%; + + // Children never take the drag events, so dragleave only fires on leaving the zone. + > * { + pointer-events: none; + } +} + +.file-drop-zone:hover { + border-color: var(--ot-color-link); } -.file-drop-zone:hover, .file-drop-zone:focus-visible { - border-color: currentColor; - outline: none; + border-color: var(--ot-color-link); + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; } -.file-input { - display: none; +.file-drop-zone--over { + background: color-mix(in srgb, var(--ot-color-link) 7%, var(--ot-color-surface)); + border-color: var(--ot-color-link); + border-style: solid; } -.file-uploader-card, -.file-uploader-content { - min-width: 0; - width: 100%; +.file-drop-zone--error, +.file-drop-zone--error:hover { + border-color: var(--ot-color-error); + border-style: solid; } -.selected-upload { +.file-drop-zone__icon { align-items: center; - display: grid; - gap: 0.75rem; - grid-template-columns: auto minmax(0, 1fr) 48px; - min-width: 0; + background: color-mix(in srgb, var(--ot-color-link) 12%, transparent); + border-radius: var(--ot-radius-circle); + color: var(--ot-color-link); + display: flex; + height: 48px; + justify-content: center; + margin-bottom: 6px; + transition: transform 150ms cubic-bezier(0.23, 1, 0.32, 1); + width: 48px; + + .mat-icon { + font-size: 26px; + height: 26px; + width: 26px; + } } -.selected-upload__name { - min-width: 0; +.file-drop-zone:hover .file-drop-zone__icon, +.file-drop-zone--over .file-drop-zone__icon { + transform: translateY(-2px); +} + +.file-drop-zone__icon--error { + background: color-mix(in srgb, var(--ot-color-error) 12%, transparent); + color: var(--ot-color-error); +} + +.file-drop-zone--error:hover .file-drop-zone__icon { + transform: none; +} + +.file-drop-zone__title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.4; +} + +.file-drop-zone__error { + color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text)); +} + +// Reads as a link: the whole zone is the button, so this is its visible call to action. +.file-drop-zone__browse { + color: var(--ot-color-link); + font-size: 0.875rem; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +} + +.file-drop-zone__formats { + color: var(--ot-color-text-muted); + font-size: 0.78rem; + line-height: 1.4; + margin-top: 4px; overflow-wrap: anywhere; - word-break: break-word; } -.upload-summary-row { +@media (prefers-reduced-motion: reduce) { + .file-drop-zone__icon { + transition: none; + } + + .file-drop-zone:hover .file-drop-zone__icon, + .file-drop-zone--over .file-drop-zone__icon { + transform: none; + } +} + +// --- Selected files --- + +.upload-file-list { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; +} + +.upload-file-row { align-items: center; - display: grid; - gap: 0.5rem; - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) 48px; + background: var(--ot-color-surface); + border: 1px solid color-mix(in srgb, var(--ot-color-border) 40%, transparent); + border-radius: var(--ot-radius-md); + box-sizing: border-box; + display: flex; + gap: 10px; + min-height: 56px; min-width: 0; + padding: 6px 6px 6px 12px; } -.upload-summary-row__label { - align-items: center; +.upload-file-row--placeholder { + background: transparent; + border-style: dashed; + padding-right: 12px; +} + +.upload-file-row__icon { + color: var(--ot-color-link); + flex-shrink: 0; +} + +.upload-file-row--placeholder .upload-file-row__icon { + color: var(--ot-color-text-muted); +} + +.upload-file-row__text { display: flex; - gap: 0.25rem; + flex: 1 1 auto; + flex-direction: column; min-width: 0; } -@media (max-width: 639.98px) { - .file-uploader-content { - padding-inline: 0.75rem; - } +.upload-file-row__name { + font-weight: 600; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - .file-drop-zone { - min-height: clamp(12rem, 50dvh, 18rem); - padding: 1.25rem; - } +.upload-file-row__meta { + color: var(--ot-color-text-muted); + font-size: 0.78rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - .upload-summary-row { - grid-template-columns: minmax(0, 1fr) 48px; - } +.upload-chip { + background: color-mix(in srgb, var(--ot-color-text) 6%, transparent); + border: 1px solid color-mix(in srgb, var(--ot-color-border) 45%, transparent); + border-radius: var(--ot-radius-pill); + color: var(--ot-color-text-muted); + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.5; + padding: 1px 10px; + white-space: nowrap; +} - .upload-summary-row__label { - grid-column: 1 / -1; +.upload-chip--success { + background: color-mix(in srgb, var(--ot-color-success) 12%, transparent); + border-color: color-mix(in srgb, var(--ot-color-success) 40%, transparent); + color: color-mix(in srgb, var(--ot-color-success) 80%, var(--ot-color-text)); +} + +.upload-chip--error { + background: color-mix(in srgb, var(--ot-color-error) 12%, transparent); + border-color: color-mix(in srgb, var(--ot-color-error) 40%, transparent); + color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text)); +} + +@media (max-width: 639.98px) { + .file-drop-zone { + min-height: 160px; + padding: 16px 12px; } .uploading, diff --git a/src/app/common/file-uploader/file-uploader.component.spec.ts b/src/app/common/file-uploader/file-uploader.component.spec.ts index 07855faa53..7d8462d58d 100644 --- a/src/app/common/file-uploader/file-uploader.component.spec.ts +++ b/src/app/common/file-uploader/file-uploader.component.spec.ts @@ -42,7 +42,7 @@ describe('FileUploaderComponent responsive selected-file state', () => { fixture.detectChanges(); }); - it('wraps a long selected filename and exposes a named remove control', () => { + it('keeps a long selected filename readable and exposes a named remove control', () => { const file = new File( ['portfolio'], 'A very long learning summary report filename that must remain readable on a phone.pdf', @@ -59,6 +59,7 @@ describe('FileUploaderComponent responsive selected-file state', () => { '.selected-upload button', ) as HTMLButtonElement; expect(name.textContent).toContain(file.name); + expect(name.getAttribute('title')).toBe(file.name); expect(remove.getAttribute('aria-label')).toBe(`Remove ${file.name}`); expect(component.readyToUpload()).toBe(true); }); diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index c401561970..20a7f96452 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -89,6 +89,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { public shownUploadZones: UploadZone[] = []; public uploadZones: UploadZone[] = []; public dropSupported: boolean = true; + /** The zone a file is being dragged over, for the drag-over style only. */ + public dragOverZone: UploadZone | null = null; constructor( private userService: UserService, @@ -128,19 +130,22 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.uploadingInfo = null; } - public onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent, upload?: UploadZone) { event.preventDefault(); event.stopPropagation(); + this.dragOverZone = upload ?? null; } public onDragLeave(event: DragEvent) { event.preventDefault(); event.stopPropagation(); + this.dragOverZone = null; } public onFileDropped(event: DragEvent, upload: UploadZone) { event.preventDefault(); event.stopPropagation(); + this.dragOverZone = null; const file = event.dataTransfer?.files?.[0]; if (file) { @@ -323,6 +328,45 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.onCancelUpload?.(); } + /** What the drop zone asks for, e.g. "PDF" or "code file". */ + public dropNoun(upload: UploadZone): string { + const type = upload.display.type; + if (type === 'PDF' || type === 'image') { + return type; + } + return `${type === 'zip' ? 'ZIP' : type} file`; + } + + /** The accepted formats in words, e.g. "PDF or PS", shortened when the list is long. */ + public acceptedFormatsLabel(upload: UploadZone): string { + const formats = upload.accepts.map((ext) => ext.toUpperCase()); + const previewLimit = 4; + if (formats.length > previewLimit) { + const rest = formats.length - previewLimit; + return `${formats.slice(0, previewLimit).join(', ')} and ${rest} more`; + } + if (formats.length <= 1) { + return formats.join(''); + } + return `${formats.slice(0, -1).join(', ')} or ${formats[formats.length - 1]}`; + } + + /** A file size in words, e.g. "1.2 MB". */ + public formatSize(bytes: number | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) { + return ''; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const kb = bytes / 1024; + if (kb < 1024) { + return `${kb.toFixed(kb < 10 ? 1 : 0)} KB`; + } + const mb = kb / 1024; + return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`; + } + // onClickFailureCancelInternal() { // console.log('onClickFailureCancelInternal'); // } diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html index 19ed440ed1..9b168548fc 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html @@ -1,58 +1,73 @@
    - @if (hasRequirements) { -

    Files required: {{ requiredFileCount }}

    + -
      - @for (summary of summaries; track $index) { -
    • -

      - Required file type: {{ summary.categoryLabel }} -

      - @if (summary.name) { -

      {{ summary.name }}

      - } -

      - Accepted formats: - @if (summary.extensions.length) { - {{ summary.previewExtensions.join(', ') }} - @if (summary.hasMoreExtensions) { - , … +

      +

      What to upload

      + + @if (hasRequirements) { +
        +
      • + + {{ requiredFileCountLabel }} +
      • + + @for (summary of summaries; track $index) { +
      • + +
        +

        + {{ summary.categoryLabel }} + &ngsp;·&ngsp; + + @if (summary.extensions.length) { + @if (summary.hasMoreExtensions) { + {{ summary.previewExtensions.join(', ') }}, and more + } @else { + {{ formatList(summary.previewExtensions) }} + } + } @else { + Not specified for this task + } + +

        + + @if (summary.maxSizeLabel) { +

        Up to {{ summary.maxSizeLabel }}

        } - } @else { - Not specified for this task - } -

        -

        - Maximum size: Not provided by the server. Upload limits still apply. -

        - @if (summary.hasMoreExtensions) { - -
          - @for (extension of summary.extensions; track extension) { -
        • {{ extension }}
        • + @if (showDescription(summary)) { +

          {{ summary.name }}

          } -
        - } -
      • - } -
      - } @else { -

      - Upload requirements for this task are not currently available. Contact your teaching team if - this message persists. -

      - } + + @if (summary.hasMoreExtensions) { + +
        + @for (extension of summary.extensions; track extension) { +
      • {{ extension }}
      • + } +
      + } +
      +
    • + } +
    + } @else { +

    + Upload requirements for this task are not currently available. Contact your teaching team if + this message persists. +

    + } +
    diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss index 37a5354fe8..7aa51e7bd1 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss @@ -1,63 +1,122 @@ +@use '../../../../unit-hub/hub-card' as hub; + +@include hub.card-theme; + +// A quiet callout: info icon, then a heading and one icon row per fact. .task-upload-requirements { - margin-bottom: 1em; - margin-top: 1em; + @include hub.callout(raised); min-width: 0; } +.requirements-callout-icon { + flex-shrink: 0; +} + +.requirements-body { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; +} + +.requirements-heading { + margin: 0; + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 650; + line-height: 1.4; +} + .requirements-list { - list-style: none; + display: flex; + flex-direction: column; + gap: 6px; margin: 0; padding: 0; + list-style: none; +} + +.requirement-row { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 10px; + color: var(--ot-color-text); + overflow-wrap: anywhere; + + > mat-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + margin-top: 2px; + color: var(--ot-color-text-muted); + font-size: 18px; + } +} + +.requirement-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + + p { + margin: 0; + } } -.requirement { - padding: 0.5em 0.75em; - margin-bottom: 0.5em; - border: 1px solid #ddd; - border-radius: 4px; - word-break: break-word; +.requirement-label { + font-weight: 600; +} + +.requirement-separator { + color: var(--ot-color-text-muted); +} + +.requirement-name, +.requirement-max-size { + color: var(--ot-color-text-muted); } .requirements-missing { - color: inherit; - font-style: italic; + margin: 0; + color: var(--ot-color-text-muted); } .extensions-toggle { - background: none; - border: none; + align-self: flex-start; + margin-top: 2px; padding: 0; - color: inherit; - text-decoration: underline; + border: none; + border-radius: var(--ot-radius-xs); + background: none; + color: var(--ot-color-link); cursor: pointer; - font-size: inherit; + font: inherit; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; - &:hover, - &:focus { - outline: 2px solid currentColor; - outline-offset: 3px; + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; } } .extensions-list { display: flex; flex-wrap: wrap; - gap: 0.25em 0.75em; - list-style: none; - margin: 0.5em 0 0; + gap: 4px 6px; + margin: 6px 0 0; padding: 0; + list-style: none; li { - border: 1px solid currentColor; - border-radius: 3px; - padding: 0.1em 0.5em; - } -} - -// Narrow screens: stack extension chips full-width instead of relying on wrapping alone -@media (max-width: 480px) { - .extensions-list { - flex-direction: column; - gap: 0.25em; + padding: 1px 8px; + border: 1px solid color-mix(in srgb, var(--ot-color-border) 45%, transparent); + border-radius: var(--ot-radius-pill); + color: var(--ot-color-text-muted); + font-size: 0.78rem; } } diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts index 7dce66a720..49696713c1 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts @@ -1,4 +1,5 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatIconModule} from '@angular/material/icon'; import {UploadRequirement} from 'src/app/api/models/task-definition'; import {TaskUploadRequirementsComponent} from './task-upload-requirements.component'; @@ -8,6 +9,7 @@ describe('TaskUploadRequirementsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ + imports: [MatIconModule], declarations: [TaskUploadRequirementsComponent], }).compileComponents(); @@ -41,8 +43,8 @@ describe('TaskUploadRequirementsComponent', () => { ]); const text = (fixture.nativeElement as HTMLElement).textContent; - expect(text).toContain('Files required:'); - expect(text).toContain('2'); + expect(text).toContain('What to upload'); + expect(text).toContain('2 files required'); expect(text).toContain('Document'); expect(text).toContain('PDF'); expect(text).toContain('Spreadsheet'); @@ -51,6 +53,22 @@ describe('TaskUploadRequirementsComponent', () => { expect(text).toContain('XLSX'); }); + it('reads as icon rows: count, then type and formats, then a differing description', () => { + setRequirements([{key: 'file0', name: 'Demo document', type: 'document'}]); + + const text = (fixture.nativeElement as HTMLElement).textContent!.replace(/\s+/g, ' '); + expect(text).toContain('1 file required'); + expect(text).toContain('Document · PDF or PS'); + expect(text).toContain('Demo document'); + expect(fixture.nativeElement.querySelectorAll('.requirement-row mat-icon').length).toBe(2); + }); + + it('hides the description when it only repeats the type name', () => { + setRequirements([{key: 'file0', name: 'Document', type: 'document'}]); + + expect(fixture.nativeElement.querySelector('.requirement-name')).toBeNull(); + }); + it('does not show an expand control when the extension list is short', () => { setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); @@ -101,8 +119,20 @@ describe('TaskUploadRequirementsComponent', () => { setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); const text = (fixture.nativeElement as HTMLElement).textContent; - expect(text).toContain('Maximum size:'); - expect(text).toContain('Not provided by the server'); + expect(component.summaries[0].maxSizeLabel).toBeNull(); + expect(fixture.nativeElement.querySelector('.requirement-max-size')).toBeNull(); + expect(text).not.toContain('Not provided by the server'); + expect(text).not.toMatch(/\d+\s?(KB|MB|GB)/); + }); + + it('shows a size limit only when one is provided', () => { + setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); + component.summaries[0].maxSizeLabel = '10 MB'; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.requirement-max-size').textContent).toContain( + '10 MB', + ); }); }); diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts index 3e9652f0e5..680de9cd8a 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts @@ -43,6 +43,25 @@ export class TaskUploadRequirementsComponent implements OnChanges { return this.summaries.length; } + public get requiredFileCountLabel(): string { + const count = this.requiredFileCount; + return `${count} ${count === 1 ? 'file' : 'files'} required`; + } + + /** Joins a list as "A, B or C". */ + public formatList(items: readonly string[]): string { + if (items.length <= 1) { + return items.join(''); + } + return `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`; + } + + /** The file's own description, only when it says more than the type name does. */ + public showDescription(summary: UploadRequirementSummary): boolean { + const name = summary.name?.trim().toLowerCase(); + return !!name && name !== summary.categoryLabel.toLowerCase(); + } + public isExpanded(summary: UploadRequirementSummary): boolean { return this.expandedKeys.has(summary.key); } diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts index 94a219e580..c776c6a629 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts @@ -9,12 +9,25 @@ const CATEGORY_LABELS: Record = { zip: 'Archive', }; +const CATEGORY_ICONS: Record = { + document: 'description', + csv: 'table_chart', + code: 'code', + image: 'image', + zip: 'folder_zip', +}; + export const EXTENSION_PREVIEW_LIMIT = 8; export interface UploadRequirementSummary { key: string; name: string; categoryLabel: string; + icon: string; + // The API does not expose a per-file size limit today. When it does, set this + // and the requirement row shows it. Until then nothing is shown, so no limit is + // invented for the student. + maxSizeLabel: string | null; extensions: string[]; previewExtensions: string[]; hasMoreExtensions: boolean; @@ -34,6 +47,8 @@ export function summariseUploadRequirement( categoryLabel: knownType ? CATEGORY_LABELS[type as keyof typeof ACCEPTED_TYPES] : type || 'File', + icon: knownType ? CATEGORY_ICONS[type as keyof typeof ACCEPTED_TYPES] : 'insert_drive_file', + maxSizeLabel: null, extensions, previewExtensions: extensions.slice(0, EXTENSION_PREVIEW_LIMIT), hasMoreExtensions: extensions.length > EXTENSION_PREVIEW_LIMIT, diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index b6b36b6ef4..43eb261320 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -1,216 +1,280 @@ -
    -

    Submit {{ task.definition.abbreviation }} {{ task.definition.name }}

    - - Submission type - - @for (option of submissionTypeOptions; track option.id) { - {{ option.label }} - } - - -
    +
    +

    Submit task

    +
    + {{ task.definition.abbreviation }} + {{ task.definition.name }} + @if (dueDate) { + + + Due {{ dueDate | date: 'EEE d MMM' }} + + } +
    +
    - -
    - @if (isGroupStage && !uploadStarted) { - - -
    -

    Team Contribution

    -

    - Please rate each team member's effort on {{ task.definition.name }}. -

    -

    Click the same rating twice to record no contribution.

    -
    + +
    +
    + Submit as + + + + @if (statusFor(submissionType); as status) { + + } @else { + + } + {{ selectedSubmissionTypeLabel }} + + + @for (option of submissionTypeOptions; track option.id) { + + + @if (statusFor(option.id); as status) { + + } @else { + + } + {{ option.label }} + + + } + +
    - 1 && !uploadStarted) { +
      + @for (step of steps; track step.stage) { +
    1. - - - + + {{ step.label }} + @if ($index < currentStepIndex) { + (done) + } +
    2. + } +
    } + @if (isGroupStage && !uploadStarted) { +
    +
    +

    Team contribution

    +

    + Please rate each team member's effort on {{ task.definition.name }}. +

    +

    + Click the same rating twice to record no contribution. +

    +
    - - - @if (!uploadStarted) { -
    -

    - Select {{ submissionType === 'need_help' ? 'files' : 'evidence' }} to upload -

    - - @if (submissionType === 'need_help') { -

    - Upload the required files so your tutor can help with this task, even if they are - not finished yet. + + +

    + } + +
    + @if (!uploadStarted) { + @if (submissionType === 'need_help') { +

    + Upload the required files so your tutor can help with this task, even if they are not + finished yet. +

    + } @else { +

    + Upload files that show your evidence of work until each required file has been selected. +

    + } + } + + + + + +
    + + @if (!uploadStarted && isCommentsStage) { +
    +
    + @if (submissionType === 'need_help') { +

    What do you need help with?

    +

    + Add a short comment so your tutor knows what help you need on this task. +

    + } @else { +

    Final comments

    + @if (task.definition.assessInPortfolioOnly) { +

    + Please specify which areas of your submission you would like feedback on.

    } @else { -

    - Upload files that show your evidence of work until each required file has been - selected. +

    + Add an optional note for your tutor to read while assessing this submission.

    } + } +
    + + + Comment + + + +
    + Character count: {{ comment.length }} + @if (requiresComment) { + (Min. {{ minCommentLength }}) + } +
    +
    + + @if (privacyPolicy.privacy) { +
    +
    +

    Declaration

    +

    {{ privacyPolicy.privacy }}

    - } - - - - - - +
    + - @if (!uploadStarted && isCommentsStage) { -
    - - -
    - @if (submissionType === 'need_help') { -

    What do you need help with?

    -

    - Add a short comment so your tutor knows what help you need on this task. -

    - } @else { -

    Final comments

    - @if (task.definition.assessInPortfolioOnly) { -

    - Please specify which areas of your submission you would like feedback on. -

    - } @else { -

    - Add an optional note for your tutor to read while assessing this submission. -

    - } - } -
    - - - Comment - - - -
    - Character count: {{ comment.length }} - @if (requiresComment) { - (Min. {{ minCommentLength }}) - } -
    -
    -
    - - @if (privacyPolicy.privacy) { - - -
    -

    Declaration

    -

    {{ privacyPolicy.privacy }}

    -
    - -
    - - - @if (showPlagiarism) { -
    - {{ privacyPolicy.plagiarism }} -
    - } -
    -
    -
    - } -
    + @if (showPlagiarism) { +

    + {{ privacyPolicy.plagiarism }} +

    + } +
    +
    + } }
    @if (!uploadStarted) { - - - @if (isGroupStage) { - - } +
    + @if (continueHint; as hint) { + {{ hint }} + } - @if (isDetailsStage && showCommentsSection) { - - } + @if (isDetailsStage && showGroupSection) { + + } - @if (isDetailsStage && showGroupSection) { - - } + @if (isCommentsStage) { + + } - @if (isCommentsStage) { - - } + @if (isGroupStage) { + + } - @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { - - } + @if (isDetailsStage && showCommentsSection) { + + } + + @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { + + } +
    } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.reviewed.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.reviewed.spec.ts index 7e2343867f..6ada34b207 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.reviewed.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.reviewed.spec.ts @@ -58,7 +58,7 @@ describe('UploadSubmissionModalComponent upload guidance', () => { const button = root.querySelector('.file-drop-zone')!; const input = root.querySelector('input[type=file]')!; const help = root.querySelector('.task-upload-requirements')!; - expect(help.textContent).toContain('Files required: 1'); + expect(help.textContent).toContain('1 file required'); expect(button.getAttribute('aria-describedby')).toBe(help.id); expect(input.getAttribute('aria-describedby')).toBe(help.id); expect(help.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index 446858ca71..97641c3a30 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -1,77 +1,387 @@ +// Submit task dialog: standard title, content and actions with 24px padding and no +// nested elevated cards. Colours come only from --ot-* tokens. + +// --- Header --- + .submission-dialog-header { - align-items: start; - display: grid; - gap: 1rem; - grid-template-columns: minmax(0, 1fr) minmax(16rem, 24rem); - padding-inline: 1.5rem; + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + padding: 20px 24px 12px; +} + +:host .submission-dialog-title { + margin: 0; + padding: 0; + color: var(--ot-color-text); + font-size: 1.25rem; + font-weight: 650; + line-height: 1.35; + + // Material draws a spacer before the title; the header padding does that job. + &::before { + display: none; + } } -.submission-dialog-header h2 { +.submission-task-line { + display: flex; min-width: 0; - overflow-wrap: anywhere; - padding-inline: 0; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + color: var(--ot-color-text); + font-size: 0.9rem; + line-height: 1.4; +} + +.submission-task-chip { + flex-shrink: 0; + padding: 1px 8px; + border: 1px solid color-mix(in srgb, var(--ot-color-link) 35%, transparent); + border-radius: var(--ot-radius-pill); + background: color-mix(in srgb, var(--ot-color-link) 10%, transparent); + color: var(--ot-color-text); + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.02em; } -.submission-type-field { - margin-top: 0.75rem; +.submission-task-name { min-width: 0; - width: 100%; + font-weight: 600; + overflow-wrap: anywhere; } -:host mat-dialog-content { +.submission-task-due { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--ot-color-text-muted); + font-size: 0.82rem; + white-space: nowrap; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +.submission-task-due--past { + color: var(--ot-urgency-overdue); +} + +// --- Content --- + +:host .submission-dialog-content { max-height: min(72dvh, 48rem); min-width: 0; + padding: 4px 24px 24px; overscroll-behavior: contain; } -.submission-dialog-actions { - gap: 0.5rem; - min-height: 64px; +.submission-dialog-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 16px; + color: var(--ot-color-text); } -.cancel-action { - margin-right: auto; +// "Submit as" sits on its own row: a label, then a compact themed picker. +.submission-type-row { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding-top: 4px; } -@media (max-width: 639.98px) { - .submission-dialog-header { - display: flex; - flex-direction: column; - gap: 0; - padding-inline: 1rem; +.submission-type-label { + color: var(--ot-color-text); + font-size: 0.9rem; + font-weight: 600; +} + +.submission-type-select { + display: flex; + box-sizing: border-box; + width: min(100%, 18rem); + height: 44px; + align-items: center; + padding: 0 12px 0 8px; + border: 1px solid var(--ot-color-control-border); + border-radius: var(--ot-radius-md); + background-color: var(--ot-color-surface); + color: var(--ot-color-text); + font-size: 0.9rem; + + --mat-select-enabled-trigger-text-color: var(--ot-color-text); + --mat-select-placeholder-text-color: var(--ot-color-text-muted); + --mat-select-disabled-trigger-text-color: var(--ot-color-disabled-text); + --mat-select-enabled-arrow-color: var(--ot-color-text-muted); + --mat-select-focused-arrow-color: var(--ot-color-link); + + &:focus-visible, + &.mat-mdc-select-focused { + outline: none; + border-color: var(--ot-color-focus); + box-shadow: 0 0 0 1px var(--ot-color-focus); } - .submission-dialog-header h2 { - font-size: 1.25rem; - line-height: 1.3; - padding-bottom: 0; + &.mat-mdc-select-disabled { + border-color: color-mix(in srgb, var(--ot-color-border) 50%, transparent); + background-color: transparent; } +} + +.submission-type-option { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} - .submission-type-field { - margin-top: 0.25rem; +.submission-type-option__glyph { + flex: none; + width: 24px; + height: 20px; + color: var(--ot-color-link); + font-size: 20px; +} + +// status-icon is a 36px circle by default, sized by an important size-9 utility in the +// utilities layer, which no component rule can outrank. Scaling the spacing unit on the +// icon's host turns that same utility into a 24px circle in the picker and its menu. +.submission-type-option__icon { + --spacing: calc(24px / 9); + display: inline-flex; + flex: none; + + ::ng-deep .status-chip mat-icon { + width: 14px; + height: 14px; + font-size: 14px; } +} + +::ng-deep .submission-type-panel { + --mat-select-panel-background-color: var(--ot-color-surface-raised); + --mat-option-label-text-color: var(--ot-color-text); + --mat-option-hover-state-layer-color: var(--ot-color-hover); + --mat-option-selected-state-layer-color: var(--ot-color-selected); + --mat-option-selected-state-label-text-color: var(--ot-color-text); + border: 1px solid var(--ot-color-divider); + border-radius: var(--ot-radius-md) !important; +} - :host mat-dialog-content { - max-height: calc(100dvh - 13rem); - padding-inline: 0.75rem; +// --- Steps --- + +.submission-steps { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.submission-step { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.4; + + // a thin connector between steps + & + &::before { + content: ''; + width: 20px; + height: 1px; + margin-inline: 4px 2px; + background: color-mix(in srgb, var(--ot-color-border) 60%, transparent); } +} + +.submission-step__marker { + display: inline-flex; + width: 22px; + height: 22px; + flex: none; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--ot-color-border) 70%, transparent); + border-radius: var(--ot-radius-circle); + font-size: 0.75rem; + font-weight: 650; - .submission-dialog-actions { - align-items: stretch; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - padding: 0.75rem; + mat-icon { + width: 14px; + height: 14px; + font-size: 14px; } +} - .submission-dialog-actions button { - margin: 0; +.submission-step--current { + color: var(--ot-color-text); + font-weight: 600; + + .submission-step__marker { + border-color: var(--ot-color-primary); + background: var(--ot-color-primary); + color: var(--ot-color-on-primary); + } +} + +.submission-step--done .submission-step__marker { + border-color: color-mix(in srgb, var(--ot-color-link) 45%, transparent); + background: color-mix(in srgb, var(--ot-color-link) 12%, transparent); + color: var(--ot-color-link); +} + +// --- Sections --- + +.submission-section { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; +} + +.submission-section__heading { + margin: 0 0 2px; + color: var(--ot-color-text); + font-size: 1rem; + font-weight: 650; + line-height: 1.4; +} + +.submission-section__lead { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.submission-character-count { + margin-top: -4px; + color: var(--ot-color-text-muted); + font-size: 0.82rem; + text-align: right; +} + +.submission-declaration { + padding-top: 16px; + border-top: 1px solid var(--ot-color-divider); +} + +// --- Actions --- + +:host .submission-dialog-actions { + display: flex; + min-height: 0; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + padding: 16px 24px; + border-top: 1px solid var(--ot-color-divider); + + button { min-height: 44px; - min-width: 0; - white-space: normal; + margin: 0; + } +} + +.submission-dialog-actions__forward { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + +.submission-continue-hint { + color: var(--ot-color-text-muted); + font-size: 0.82rem; + line-height: 1.4; + text-align: right; +} + +// The cancel discards the selection, so it keeps an error-toned label on the outline. +:host .cancel-action:not(:disabled) { + --mat-button-outlined-label-text-color: color-mix( + in srgb, + var(--ot-color-error) 85%, + var(--ot-color-text) + ); + --mat-button-outlined-outline-color: color-mix( + in srgb, + var(--ot-color-error) 40%, + var(--ot-color-border) + ); + background-color: transparent; +} + +:host .cancel-action:not(:disabled):hover { + background-color: color-mix(in srgb, var(--ot-color-error) 7%, transparent); +} + +// --- Phones: the dialog fills the screen --- + +@media (max-width: 599.98px) { + ::ng-deep .cdk-overlay-pane.responsive-submission-dialog { + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh; + max-height: 100dvh !important; + + .mat-mdc-dialog-surface { + border-radius: 0; + } + } + + .submission-dialog-header { + padding: 16px 16px 8px; + } + + :host .submission-dialog-content { + max-height: none; + flex: 1 1 auto; + padding: 4px 16px 16px; + } + + .submission-type-select { + width: 100%; + } + + :host .submission-dialog-actions { + padding: 12px 16px; + } + + .submission-dialog-actions__forward { + width: 100%; + + button { + flex: 1 1 0; + } + } + + .submission-continue-hint { + width: 100%; + text-align: left; } .cancel-action { - grid-column: 1 / -1; - grid-row: 2; + order: 2; + width: 100%; } } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 806e0131d0..8abf6241dc 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -110,4 +110,27 @@ describe('UploadSubmissionModalComponent', () => { expect(component.uploadSubmitLocked).toBe(false); expect(component.currentStage).toBe('details'); }); + + it('lists the steps and marks the current one', () => { + expect(component.steps.map((step) => step.label)).toEqual(['Upload files', 'Comments']); + expect(component.currentStepIndex).toBe(0); + + component.onReadyChange(true); + component.goToCommentsStage(); + expect(component.currentStepIndex).toBe(1); + }); + + it('explains a disabled forward button and clears once the file is chosen', () => { + expect(component.shouldDisableNext()).toBe(true); + expect(component.continueHint).toBe('Add the required file to continue'); + + component.onReadyChange(true); + expect(component.continueHint).toBeNull(); + }); + + it('shows a status icon only for submission types that are task statuses', () => { + expect(component.statusFor('ready_for_feedback')).toBe('ready_for_feedback'); + expect(component.statusFor('reupload_evidence')).toBeNull(); + expect(component.selectedSubmissionTypeLabel).toBe(''); + }); }); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 57ba7972f0..b6260ae6ce 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, Inject, OnInit, ViewChild} from '@an import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; import {MemberContribution} from 'src/app/api/models/groups/group'; import {Task} from 'src/app/api/models/task'; -import {TaskStatusEnum} from 'src/app/api/models/task-status'; +import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; import {ProjectService} from 'src/app/api/services/project.service'; import {TaskService} from 'src/app/api/services/task.service'; import {FileUploaderComponent} from 'src/app/common/file-uploader/file-uploader.component'; @@ -18,6 +18,11 @@ interface UploadSubmissionTypeOption { label: string; } +export interface UploadSubmissionStep { + stage: UploadStage; + label: string; +} + interface UploadSubmissionFileSpec { name: string; type: string; @@ -167,6 +172,87 @@ export class UploadSubmissionModalComponent implements OnInit { return 'Make a comment...'; } + /** The steps this submission goes through, in order. */ + public get steps(): UploadSubmissionStep[] { + const steps: UploadSubmissionStep[] = []; + if (this.showGroupSection) { + steps.push({stage: 'group', label: 'Rate team'}); + } + steps.push({stage: 'details', label: 'Upload files'}); + if (this.showCommentsSection) { + steps.push({ + stage: 'comments', + label: this.submissionType === 'need_help' ? 'Ask for help' : 'Comments', + }); + } + return steps; + } + + public get currentStepIndex(): number { + return Math.max( + 0, + this.steps.findIndex((step) => step.stage === this.currentStage), + ); + } + + /** The task's due or target date, or null when it cannot be worked out. */ + public get dueDate(): Date | null { + try { + const date = this.task.localDueDate?.(); + return date instanceof Date && !Number.isNaN(date.getTime()) ? date : null; + } catch { + return null; + } + } + + public get isPastDue(): boolean { + try { + return this.task.isPastDueDate?.() === true; + } catch { + return false; + } + } + + /** A task status for the icon beside a submission type, when that type is a status. */ + public statusFor(type: UploadSubmissionType): TaskStatusEnum | null { + return TaskStatus.STATUS_KEYS.includes(type as TaskStatusEnum) + ? (type as TaskStatusEnum) + : null; + } + + public get selectedSubmissionTypeLabel(): string { + return ( + this.submissionTypeOptions.find((option) => option.id === this.submissionType)?.label ?? '' + ); + } + + /** Says why the forward button is disabled, or null when it is enabled. */ + public get continueHint(): string | null { + if (this.isGroupStage) { + return this.shouldDisableNext() ? 'Rate a team member to continue' : null; + } + + const forwardDisabled = + this.isDetailsStage && this.showCommentsSection + ? this.shouldDisableNext() + : this.shouldDisableSubmit(); + if (!forwardDisabled) { + return null; + } + + if (!this.isUploaderReady) { + return this.task.definition.uploadRequirements.length > 1 + ? 'Add the required files to continue' + : 'Add the required file to continue'; + } + + if (this.requiresComment && this.comment.trim().length < this.minCommentLength) { + return `Add a comment of at least ${this.minCommentLength} characters`; + } + + return null; + } + public get hasRatedTeamMember(): boolean { return this.team.memberContributions.some((member) => !!member.rating); } From 038743a32692a3fd877c6cee018e3d2243ffafcf Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 01:15:12 +1000 Subject: [PATCH 47/88] fix(user-icon): draw initials when the avatar photo hash is unavailable --- ...user-icon.component.secure-context.spec.ts | 18 +++++++++ .../common/user-icon/user-icon.component.ts | 37 +++++++++++++------ 2 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 src/app/common/user-icon/user-icon.component.secure-context.spec.ts diff --git a/src/app/common/user-icon/user-icon.component.secure-context.spec.ts b/src/app/common/user-icon/user-icon.component.secure-context.spec.ts new file mode 100644 index 0000000000..65c83f3c0f --- /dev/null +++ b/src/app/common/user-icon/user-icon.component.secure-context.spec.ts @@ -0,0 +1,18 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {UserIconComponent} from './user-icon.component'; + +describe('UserIconComponent outside a secure context', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('still resolves the background without SubtleCrypto', async () => { + vi.stubGlobal('crypto', {}); + const component = new UserIconComponent({currentUser: null} as never); + component.user = {email: 'demo@example.com', name: 'Demo Student'} as never; + + const url = await ( + component as unknown as {backgroundUrl: () => Promise} + ).backgroundUrl(); + + expect(url).toBeNull(); + }); +}); diff --git a/src/app/common/user-icon/user-icon.component.ts b/src/app/common/user-icon/user-icon.component.ts index 212eee6e02..fbe3ac907b 100644 --- a/src/app/common/user-icon/user-icon.component.ts +++ b/src/app/common/user-icon/user-icon.component.ts @@ -72,13 +72,26 @@ export class UserIconComponent implements AfterViewInit, OnChanges { constructor(private userService: UserService) {} - private async backgroundUrl(): Promise { - const hash = await this.sha256(this.email?.trim().toLowerCase() ?? ''); - return `https://www.gravatar.com/avatar/${hash}.png?default=blank&size=${this.size * 4}`; + /** + * The Gravatar photo behind the initials, or null when it cannot be worked out. + * crypto.subtle only exists on https or localhost, so opening OnTrack by a LAN + * address threw here and the avatar never drew at all. Without a hash the + * initials still show. + */ + private async backgroundUrl(): Promise { + try { + const hash = await this.sha256(this.email?.trim().toLowerCase() ?? ''); + return `https://www.gravatar.com/avatar/${hash}.png?default=blank&size=${this.size * 4}`; + } catch { + return null; + } } private async sha256(value: string): Promise { const bytes = new TextEncoder().encode(value); + if (!globalThis.crypto?.subtle) { + throw new Error('SubtleCrypto is unavailable outside a secure context'); + } const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( '', @@ -239,13 +252,15 @@ export class UserIconComponent implements AfterViewInit, OnChanges { .attr('fill', 'white') .text((d) => d.text); - svg - .append('image') - .attr('xlink:href', backgroundUrl) - .attr('width', this.size) - .attr('height', this.size) - .attr('x', 0) - .attr('y', 0) - .attr('clip-path', `url(#image-clip-${id})`); + if (backgroundUrl) { + svg + .append('image') + .attr('xlink:href', backgroundUrl) + .attr('width', this.size) + .attr('height', this.size) + .attr('x', 0) + .attr('y', 0) + .attr('clip-path', `url(#image-clip-${id})`); + } } } From fc9085647b48b5de0c6a6fec7ca42e8aa5b3e046 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 01:18:17 +1000 Subject: [PATCH 48/88] fix(tasks): a smaller status hint inside the closed status picker --- .../unit-task-list.component.scss | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss index ecc2e081fa..fd07edd4ed 100644 --- a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss @@ -137,8 +137,8 @@ $my-palette: mat.m2-define-palette($md-formatif); border: 1px solid var(--ot-color-border); border-radius: 999px; background-color: var(--ot-color-surface); - // the 24px status circle sits 8px in from the pill edge, same as the text gap - padding: 0 0.75rem 0 8px; + // the status circle sits 12px in from the pill edge, same as the text inset + padding: 0 0.75rem 0 12px; color: var(--ot-color-text); font-size: 0.875rem; @@ -187,6 +187,19 @@ $my-palette: mat.m2-define-palette($md-formatif); } } +// In the closed pill the circle is only a hint of the colour, not a badge: 20px with a +// 12px glyph, so it sits inside the pill's padding instead of filling its height. +:host ::ng-deep .task-status-select .task-status-option__icon .status-chip { + width: 20px !important; + height: 20px !important; + + mat-icon { + width: 12px; + height: 12px; + font-size: 12px; + } +} + // The menu sits in the overlay, outside this component, so its colours are set on // the panel class. Both themes come from the tokens. ::ng-deep .task-status-filter-panel { From 02557bf9795b5763a01d30b421116c0ccc8dca09 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 01:27:35 +1000 Subject: [PATCH 49/88] feat(panel-layout): full-screen button for the task column Adds app-panel-fullscreen-button, which finds its panel through DI like the collapse button and renders nothing outside a panel or while stacked. It sits just before the more actions menu in the inbox and task dashboard tab bars, returns focus to itself when full screen ends, and full-screen panels fade and scale in unless reduced motion is set. Notes, history and task details keep a 960px reading width while full screen. --- .../panel-fullscreen-button.component.html | 14 +++ .../panel-fullscreen-button.component.scss | 4 + .../panel-fullscreen-button.component.ts | 86 ++++++++++++++ .../panel-layout.component.spec.ts | 106 +++++++++++++++++- .../common/panel-layout/panel.component.scss | 19 ++++ .../common/panel-layout/panel.component.ts | 5 +- src/app/doubtfire-angular.module.ts | 2 + .../task-dashboard.component.html | 8 +- .../task-dashboard.component.scss | 8 ++ .../task-dashboard.component.spec.ts | 82 +++++++++++++- .../task-dashboard.component.ts | 9 ++ .../inbox-dashboard.component.html | 9 +- .../inbox-dashboard.component.scss | 8 ++ .../inbox-dashboard.component.spec.ts | 80 +++++++++++++ .../inbox-dashboard.component.ts | 9 ++ 15 files changed, 437 insertions(+), 12 deletions(-) create mode 100644 src/app/common/panel-layout/panel-fullscreen-button.component.html create mode 100644 src/app/common/panel-layout/panel-fullscreen-button.component.scss create mode 100644 src/app/common/panel-layout/panel-fullscreen-button.component.ts diff --git a/src/app/common/panel-layout/panel-fullscreen-button.component.html b/src/app/common/panel-layout/panel-fullscreen-button.component.html new file mode 100644 index 0000000000..14657560ff --- /dev/null +++ b/src/app/common/panel-layout/panel-fullscreen-button.component.html @@ -0,0 +1,14 @@ +@if (visible) { + +} diff --git a/src/app/common/panel-layout/panel-fullscreen-button.component.scss b/src/app/common/panel-layout/panel-fullscreen-button.component.scss new file mode 100644 index 0000000000..39e07a2cbc --- /dev/null +++ b/src/app/common/panel-layout/panel-fullscreen-button.component.scss @@ -0,0 +1,4 @@ +// The button sits in its neighbours' row as if it were written there directly. +:host { + display: contents; +} diff --git a/src/app/common/panel-layout/panel-fullscreen-button.component.ts b/src/app/common/panel-layout/panel-fullscreen-button.component.ts new file mode 100644 index 0000000000..3c4697f48c --- /dev/null +++ b/src/app/common/panel-layout/panel-fullscreen-button.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + DoCheck, + ElementRef, + Input, + ViewChild, + inject, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {PanelComponent} from './panel.component'; + +/** + * Takes the `app-panel` it sits in full screen, from a control row inside that panel's own + * content, such as a tab bar. Outside a panel, or while the panels are stacked one at a + * time on a small screen, it renders nothing. + * + * Leaving full screen by any route (this button, Esc) puts focus back on the button, as + * long as focus was still somewhere in the panel. A swap to another full-screen panel + * leaves focus where the user put it. + */ +@Component({ + selector: 'app-panel-fullscreen-button', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatButtonModule, MatIconModule, MatTooltipModule], + host: {class: 'app-panel-fullscreen-button'}, + styleUrl: './panel-fullscreen-button.component.scss', + templateUrl: './panel-fullscreen-button.component.html', +}) +export class PanelFullscreenButtonComponent implements DoCheck { + /** Classes for the button, to match the icon buttons beside it. */ + @Input() public buttonClass = ''; + + public readonly panel = inject(PanelComponent, {optional: true}); + + @ViewChild('toggle', {read: ElementRef}) private toggle?: ElementRef; + + private readonly host = inject>(ElementRef); + private wasFullscreen = false; + + public get visible(): boolean { + return !!this.panel && !this.panel.stacked; + } + + public get isFullscreen(): boolean { + return !!this.panel?.isFullscreen; + } + + public get label(): string { + return this.isFullscreen ? 'Exit full screen' : `Open ${this.panel?.panelTitle} full screen`; + } + + public ngDoCheck(): void { + const fullscreen = this.isFullscreen; + if (this.wasFullscreen && !fullscreen) { + // After this check has finished, so a blur elsewhere does not land mid-render. + void Promise.resolve().then(() => this.restoreFocus()); + } + this.wasFullscreen = fullscreen; + } + + public toggleFullscreen(): void { + this.panel?.toggleFullscreen(); + } + + private restoreFocus(): void { + const button = this.toggle?.nativeElement; + // Gone when the layout left full screen because the panels stacked. + if (!button || !this.host.nativeElement.contains(button)) { + return; + } + const panelElement = this.host.nativeElement.closest('app-panel'); + // Another panel went full screen in this one's place, so this one is out of reach. + if (panelElement?.hasAttribute('inert')) { + return; + } + const active = document.activeElement; + const focusWasInPanel = + !active || active === document.body || (!!panelElement && panelElement.contains(active)); + if (focusWasInPanel && active !== button) { + button.focus(); + } + } +} diff --git a/src/app/common/panel-layout/panel-layout.component.spec.ts b/src/app/common/panel-layout/panel-layout.component.spec.ts index 4e137b9879..8134c4da02 100644 --- a/src/app/common/panel-layout/panel-layout.component.spec.ts +++ b/src/app/common/panel-layout/panel-layout.component.spec.ts @@ -4,6 +4,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {BehaviorSubject} from 'rxjs'; import {PanelCollapseButtonComponent} from './panel-collapse-button.component'; +import {PanelFullscreenButtonComponent} from './panel-fullscreen-button.component'; import {PanelLayoutComponent} from './panel-layout.component'; import {PanelStateService} from './panel-state.service'; import {PanelComponent} from './panel.component'; @@ -11,7 +12,12 @@ import {PanelComponent} from './panel.component'; // The real template is set in the test module below, so this one stays empty. @Component({ changeDetection: ChangeDetectionStrategy.Eager, - imports: [PanelLayoutComponent, PanelComponent, PanelCollapseButtonComponent], + imports: [ + PanelLayoutComponent, + PanelComponent, + PanelCollapseButtonComponent, + PanelFullscreenButtonComponent, + ], // eslint-disable-next-line @angular-eslint/component-max-inline-declarations template: '', }) @@ -42,7 +48,11 @@ const hostTemplate = ` [minWidth]="420" [showHeader]="false" > - +
    + + +
    +

    work

    comments

    +
    `; describe('PanelLayoutComponent', () => { @@ -269,6 +280,97 @@ describe('PanelLayoutComponent', () => { expect(panel('list').hasAttribute('inert')).toBe(false); }); + describe('full-screen button', () => { + const fullscreenButton = (): HTMLButtonElement => + panel('work').querySelector('.work-tabs app-panel-fullscreen-button button'); + + it('toggles the panel it sits in, with a label, pressed state and icon to match', () => { + create(); + const toggle = fullscreenButton(); + const spy = vi.spyOn(instance('work'), 'toggleFullscreen'); + + expect(toggle.getAttribute('aria-label')).toBe('Open Selected task full screen'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(toggle.classList).toContain('mat-mdc-icon-button'); + expect(toggle.classList).toContain('work-fullscreen'); + expect(toggle.textContent.trim()).toBe('open_in_full'); + + toggle.click(); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.fullscreen).toBe('work'); + expect(panel('work').classList).toContain('app-panel--fullscreen'); + expect(panel('list').hasAttribute('inert')).toBe(true); + expect(toggle.getAttribute('aria-label')).toBe('Exit full screen'); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + expect(toggle.textContent.trim()).toBe('close_fullscreen'); + + toggle.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.fullscreen).toBeNull(); + expect(panel('work').classList).not.toContain('app-panel--fullscreen'); + }); + + it('renders nothing outside a panel', () => { + create(); + const outside = fixture.nativeElement.querySelector('.outside-panel'); + expect(outside.querySelector('app-panel-fullscreen-button')).not.toBeNull(); + expect(outside.querySelector('button')).toBeNull(); + }); + + it('renders nothing while the panels are stacked', () => { + create(); + stacked$.next({matches: true, breakpoints: {}}); + fixture.detectChanges(); + expect(fullscreenButton()).toBeNull(); + }); + + it('leaves full screen on Esc and puts focus back on the button', async () => { + create(); + fullscreenButton().click(); + fixture.detectChanges(); + panel('work').querySelector('.work-action').focus(); + expect(document.activeElement).toBe(panel('work').querySelector('.work-action')); + + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); + fixture.detectChanges(); + // Focus moves once the check that saw full screen end has finished. + await Promise.resolve(); + + expect(fixture.componentInstance.fullscreen).toBeNull(); + expect(panel('work').classList).not.toContain('app-panel--fullscreen'); + expect(document.activeElement).toBe(fullscreenButton()); + }); + + it('keeps one panel full screen at a time, swapping from comments to the task', async () => { + create(); + const comments = button('comments', 'Full screen Comments'); + comments.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.fullscreen).toBe('comments'); + + fullscreenButton().click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fullscreen).toBe('work'); + expect(fixture.nativeElement.querySelectorAll('.app-panel--fullscreen').length).toBe(1); + expect(panel('comments').classList).not.toContain('app-panel--fullscreen'); + expect(panel('comments').hasAttribute('inert')).toBe(true); + expect(panel('work').hasAttribute('inert')).toBe(false); + + button('comments', 'Full screen Comments').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fullscreen).toBe('comments'); + expect(fixture.nativeElement.querySelectorAll('.app-panel--fullscreen').length).toBe(1); + expect(fullscreenButton().getAttribute('aria-pressed')).toBe('false'); + }); + }); + it('shows one panel at a time behind tabs when stacked, and ignores collapse', () => { window.localStorage.setItem('ontrack.panels.spec.comments', '{"collapsed":true}'); create(); diff --git a/src/app/common/panel-layout/panel.component.scss b/src/app/common/panel-layout/panel.component.scss index 5d0e575c7a..57c8d76d1e 100644 --- a/src/app/common/panel-layout/panel.component.scss +++ b/src/app/common/panel-layout/panel.component.scss @@ -33,6 +33,25 @@ inset: var(--app-panel-gap, 0.75rem); z-index: 270; width: auto; + animation: app-panel-fullscreen-in 200ms cubic-bezier(0.23, 1, 0.32, 1); +} + +// Opening settles in from just under full size. Leaving is instant, back to the columns. +@keyframes app-panel-fullscreen-in { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + :host(.app-panel--fullscreen) { + animation: none; + } } .app-panel__card { diff --git a/src/app/common/panel-layout/panel.component.ts b/src/app/common/panel-layout/panel.component.ts index 28d1caaad9..04686eeddf 100644 --- a/src/app/common/panel-layout/panel.component.ts +++ b/src/app/common/panel-layout/panel.component.ts @@ -20,8 +20,9 @@ import {PanelStateService} from './panel-state.service'; * One rounded card in an `app-panel-layout`. Project actions into `[panelActions]` and a * note beside the title into `[panelSubtitle]`; everything else becomes the body. * - * A panel without a header puts an `app-panel-collapse-button` in its content's own - * control row, which finds this panel and collapses it. + * A panel without a header puts an `app-panel-collapse-button` or an + * `app-panel-fullscreen-button` in its content's own control row, which finds this panel + * and collapses it or takes it full screen. */ @Component({ selector: 'app-panel', diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 08ccf4bc90..1667f0b2d2 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -215,6 +215,7 @@ import {NotificationsPageComponent} from './common/notifications-page/notificati import {ObjectSelectComponent} from './common/obect-select/object-select.component'; import {PageContainerComponent} from './common/page-container/page-container.component'; import {PanelCollapseButtonComponent} from './common/panel-layout/panel-collapse-button.component'; +import {PanelFullscreenButtonComponent} from './common/panel-layout/panel-fullscreen-button.component'; import {PanelLayoutComponent} from './common/panel-layout/panel-layout.component'; import {PanelComponent} from './common/panel-layout/panel.component'; import {PdfViewerPanelComponent} from './common/pdf-viewer-panel/pdf-viewer-panel.component'; @@ -821,6 +822,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { PanelLayoutComponent, PanelComponent, PanelCollapseButtonComponent, + PanelFullscreenButtonComponent, ThemeToggleComponent, EmptyStateComponent, FlexLayoutModule, diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html index 69258ff67c..f66013a59d 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html @@ -55,6 +55,7 @@ +
    - @if (privacyPolicy.privacy) { -
    + @if (!uploadStarted && isCommentsStage) { +
    -

    Declaration

    -

    {{ privacyPolicy.privacy }}

    + @if (submissionType === 'need_help') { +

    What do you need help with?

    +

    + Add a short comment so your tutor knows what help you need on this task. +

    + } @else { +

    Final comments

    + @if (task.definition.assessInPortfolioOnly) { +

    + Please specify which areas of your submission you would like feedback on. +

    + } @else { +

    + Add an optional note for your tutor to read while assessing this submission. +

    + } + }
    -
    - + + Comment + + - @if (showPlagiarism) { -

    - {{ privacyPolicy.plagiarism }} -

    +
    + Character count: {{ comment.length }} + @if (requiresComment) { + (Min. {{ minCommentLength }}) }
    - } - } - - -@if (!uploadStarted) { - - + @if (privacyPolicy.privacy) { +
    +
    +

    Declaration

    +

    {{ privacyPolicy.privacy }}

    +
    -
    - @if (continueHint; as hint) { - {{ hint }} - } +
    + - @if (isDetailsStage && showGroupSection) { - + @if (showPlagiarism) { +

    + {{ privacyPolicy.plagiarism }} +

    + } +
    +
    + } } + + - @if (isCommentsStage) { - - } + @if (!uploadStarted) { + + - @if (isGroupStage) { - - } +
    + @if (continueHint; as hint) { + {{ hint }} + } - @if (isDetailsStage && showCommentsSection) { - - } + @if (isDetailsStage && showGroupSection) { + + } - @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { - - } -
    -
    + @if (isCommentsStage) { + + } + + @if (isGroupStage) { + + } + + @if (isDetailsStage && showCommentsSection) { + + } + + @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { + + } + +
    + } } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index 97641c3a30..538e57ae02 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -385,3 +385,91 @@ width: 100%; } } + +// --- Confirmation --- +// Replaces the whole dialog once the submission lands, so the last thing the +// student sees is the result rather than the form they just finished with. + +.submission-done { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 44px 24px 36px; + text-align: center; + animation: submission-done-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; + + button { + margin-block-start: 10px; + transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); + + &:active { + transform: scale(0.97); + } + } +} + +.submission-done__mark { + position: relative; + display: inline-flex; +} + +.submission-done__headline { + margin: 6px 0 0; + color: var(--ot-color-text); + font-size: 1.05rem; + font-weight: 620; + line-height: 1.35; + // Waits for the tick to finish drawing, so the two do not compete. + animation: submission-done-rise 200ms cubic-bezier(0.23, 1, 0.32, 1) both; + animation-delay: 420ms; +} + +.submission-done__detail { + max-width: 34ch; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.9rem; + line-height: 1.45; + overflow-wrap: anywhere; + animation: submission-done-rise 200ms cubic-bezier(0.23, 1, 0.32, 1) both; + animation-delay: 480ms; +} + +@keyframes submission-done-in { + from { + opacity: 0; + transform: scale(0.97); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes submission-done-rise { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .submission-done, + .submission-done__headline, + .submission-done__detail { + animation: none; + opacity: 1; + transform: none; + } + + .submission-done button { + transition: none; + } +} diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 8abf6241dc..03bfc19596 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {type Mock, beforeEach, describe, expect, it, vi} from 'vitest'; import {Task} from 'src/app/api/models/task'; import { UploadSubmissionModalComponent, @@ -96,10 +96,58 @@ describe('UploadSubmissionModalComponent', () => { 'ready_for_feedback', expect.anything(), true, + true, ); expect(dialogRef.close).toHaveBeenCalledWith({value: task}); }); + it('holds the dialog open on the confirmation, then closes on the same task', () => { + vi.useFakeTimers(); + (task.processTaskStatusChange as unknown as Mock).mockReturnValue({ + timing: 'on_time', + resubmission: false, + tone: 'success', + particles: true, + headline: 'Submitted on time. Ready for feedback', + detail: 'T1 A task', + }); + + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + component.onUploadComplete(); + + // The confirmation has taken the dialog over and nothing has closed yet. + expect(component.celebration?.headline).toBe('Submitted on time. Ready for feedback'); + expect(dialogRef.close).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(2500); + expect(dialogRef.close).toHaveBeenCalledWith({value: task}); + vi.useRealTimers(); + }); + + it('closes early when the confirmation is dismissed, and only once', () => { + vi.useFakeTimers(); + (task.processTaskStatusChange as unknown as Mock).mockReturnValue({ + timing: 'after_due', + resubmission: false, + tone: 'neutral', + particles: false, + headline: 'Submitted after the due date', + detail: 'T1 A task', + }); + + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + component.onUploadComplete(); + + component.finishCelebration(); + expect(dialogRef.close).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2500); + expect(dialogRef.close).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + it('restores selection controls after a cancelled slow upload', () => { component.uploadStarted = true; component.uploadSubmitLocked = true; diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index b6260ae6ce..afa6b06578 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -1,15 +1,27 @@ -import {ChangeDetectionStrategy, Component, Inject, OnInit, ViewChild} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; import {MemberContribution} from 'src/app/api/models/groups/group'; import {Task} from 'src/app/api/models/task'; import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; import {ProjectService} from 'src/app/api/services/project.service'; import {TaskService} from 'src/app/api/services/task.service'; +import {prefersReducedMotion} from 'src/app/common/celebrate/reduced-motion'; +import type {SubmissionCelebration} from 'src/app/common/celebrate/submission-timing'; import {FileUploaderComponent} from 'src/app/common/file-uploader/file-uploader.component'; import {AlertService} from 'src/app/common/services/alert.service'; import {EmojiService} from 'src/app/common/services/emoji.service'; import {PrivacyPolicy} from 'src/app/config/privacy-policy/privacy-policy'; +/** Everything in the confirmation has settled by ~900ms, so this leaves a beat to read it. */ +const SUBMISSION_CELEBRATION_HOLD_MS = 1900; + type UploadStage = 'group' | 'details' | 'comments'; type UploadSubmissionType = TaskStatusEnum | 'reupload_evidence' | 'test_submission'; @@ -66,7 +78,7 @@ export type UploadSubmissionModalResult = changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class UploadSubmissionModalComponent implements OnInit { +export class UploadSubmissionModalComponent implements OnInit, OnDestroy { @ViewChild(FileUploaderComponent) private fileUploader?: FileUploaderComponent; public readonly minCommentLength = 25; @@ -94,9 +106,12 @@ export class UploadSubmissionModalComponent implements OnInit { public isUploaderReady = false; public uploadStarted = false; public uploadSubmitLocked = false; + /** Set once the submission has landed. It takes over the dialog until it closes. */ + public celebration: SubmissionCelebration | null = null; private uploadResponse: UploadSubmissionResponse | null = null; private startUpload?: () => void; + private celebrationTimer: ReturnType | null = null; constructor( @Inject(MAT_DIALOG_DATA) public data: UploadSubmissionModalData, @@ -408,12 +423,50 @@ export class UploadSubmissionModalComponent implements OnInit { : response.status; this.task.updateFromJson(response, this.taskService.mapping); - this.task.processTaskStatusChange(expectedStatus as TaskStatusEnum, this.alertService, true); + // The dialog the student started this from is still the thing they are + // looking at, so the confirmation belongs here rather than over the page + // they are about to be returned to. + const celebration = this.task.processTaskStatusChange( + expectedStatus as TaskStatusEnum, + this.alertService, + true, + true, + ); + + if (celebration) { + this.showCelebration(celebration); + return; + } } this.dialogRef.close({value: this.task}); }; + /** Hold long enough for the mark to draw and the words to be read, then leave. */ + private showCelebration(celebration: SubmissionCelebration): void { + this.celebration = celebration; + this.celebrationTimer = setTimeout( + () => this.finishCelebration(), + prefersReducedMotion() ? 900 : SUBMISSION_CELEBRATION_HOLD_MS, + ); + } + + /** Also the Done button, so nobody has to wait out the hold. */ + public finishCelebration(): void { + if (this.celebrationTimer) { + clearTimeout(this.celebrationTimer); + this.celebrationTimer = null; + } + this.dialogRef.close({value: this.task}); + } + + public ngOnDestroy(): void { + if (this.celebrationTimer) { + clearTimeout(this.celebrationTimer); + this.celebrationTimer = null; + } + } + public uploadButtonClicked(): void { if (this.uploadSubmitLocked || this.isUploading) { return; From 3318e61d20eb0a62a8cd86387b6bea209c5d7a7e Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 14:35:03 +1000 Subject: [PATCH 57/88] style(submit): simplify the upload dialog and slow the confirmation --- .../celebrate/animated-check.component.scss | 14 +- .../celebrate/animated-check.component.ts | 3 + .../motion-demo/motion-demo.component.html | 4 +- .../file-uploader.component.html | 91 +++++----- .../file-uploader.component.scss | 155 ++++++++++++++++++ .../file-uploader/file-uploader.component.ts | 36 ++++ .../upload-submission-modal.component.html | 42 +++-- .../upload-submission-modal.component.scss | 67 ++++---- .../upload-submission-modal.component.spec.ts | 17 +- .../upload-submission-modal.component.ts | 24 ++- 10 files changed, 354 insertions(+), 99 deletions(-) diff --git a/src/app/common/celebrate/animated-check.component.scss b/src/app/common/celebrate/animated-check.component.scss index 9d7e204c72..28dff5670e 100644 --- a/src/app/common/celebrate/animated-check.component.scss +++ b/src/app/common/celebrate/animated-check.component.scss @@ -4,6 +4,9 @@ @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; @@ -22,8 +25,8 @@ svg { display: block; overflow: visible; // The small settle after the tick lands. Crisp, not bouncy: 6% at most. - animation: check-settle 240ms motion.$ease-out both; - animation-delay: calc(var(--check-delay, 0ms) + 520ms); + 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 { @@ -40,13 +43,14 @@ svg { .ring { stroke-linecap: round; - animation: check-draw motion.$duration-draw motion.$ease-out forwards; + animation: check-draw calc(#{motion.$duration-draw} * var(--check-pace)) motion.$ease-out + forwards; animation-delay: var(--check-delay, 0ms); } .tick { - animation: check-draw 240ms motion.$ease-out forwards; - animation-delay: calc(var(--check-delay, 0ms) + 260ms); + 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 { diff --git a/src/app/common/celebrate/animated-check.component.ts b/src/app/common/celebrate/animated-check.component.ts index ce89361bee..5fb51bb79f 100644 --- a/src/app/common/celebrate/animated-check.component.ts +++ b/src/app/common/celebrate/animated-check.component.ts @@ -20,6 +20,7 @@ export type CelebrateTone = 'success' | 'primary' | 'neutral'; '[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()', }, @@ -29,6 +30,8 @@ export class AnimatedCheckComponent { 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(); diff --git a/src/app/common/celebrate/motion-demo/motion-demo.component.html b/src/app/common/celebrate/motion-demo/motion-demo.component.html index 9385e195bf..ed4b70e20a 100644 --- a/src/app/common/celebrate/motion-demo/motion-demo.component.html +++ b/src/app/common/celebrate/motion-demo/motion-demo.component.html @@ -19,8 +19,8 @@

    Motion

    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. + 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) { diff --git a/src/app/common/file-uploader/file-uploader.component.html b/src/app/common/file-uploader/file-uploader.component.html index 0ce2a315dc..f7b1fcfc6e 100644 --- a/src/app/common/file-uploader/file-uploader.component.html +++ b/src/app/common/file-uploader/file-uploader.component.html @@ -7,18 +7,18 @@ } -
      - @if (showUploader && uploadingInfo === null && shownUploadZones.length) { +
      + @if (showUploader && uploadingInfo === null && renderedUploadZones.length) {
      - @for (upload of shownUploadZones; track upload) { - @if (!singleDropZone && showName) { + @for (upload of renderedUploadZones; track upload) { + @if (!showSummaryColumn && showName) {
      {{ uploadZones.length === 1 ? '' : $index + 1 + ' - ' }} {{ upload.display.name }}
      } - @if (singleDropZone && showName) { + @if (showSummaryColumn && showName) {
      {{ upload.display.name }}
      } @@ -72,7 +72,7 @@
      {{ upload.display.name }}
      />
      - @if (!singleDropZone && upload.model?.length > 0) { + @if (!showSummaryColumn && upload.model?.length > 0) {
      } - @if (showUploader && singleDropZone && uploadingInfo === null) { + @if (showUploader && showSummaryColumn && uploadingInfo === null) {
      Selected files
      @@ -177,49 +177,52 @@
      Selected files
      @if (showUploader && readyToUpload() && isUploading) { @if (!uploadingInfo?.complete) { -
      -
      - @for (upload of uploadZones; track upload) { - {{ upload.display.icon }} - } - arrow_right_alt - -
      + +
      + + +

      Uploading your work

      +

      {{ uploadingFileLabel }}

      - - - + +
      +

      {{ uploadingInfo?.progress ?? 0 }}%

      + +
      } - @if (uploadingInfo?.complete) { -
      -
      - - {{ uploadingInfo.success ? 'check_circle' : 'cancel' }} - + @if (uploadingInfo?.complete && (!uploadingInfo.success || showSuccessState)) { +
      + @if (uploadingInfo.success) { + +

      Upload complete

      + } @else { + +

      Upload failed

      +

      {{ uploadingInfo.error }}

      - - File Upload {{ uploadingInfo.success ? 'Successful' : 'Failed' }} - -
      - - @if (!uploadingInfo.success) { -
      -
      -

      Error Message: {{ uploadingInfo.error }}

      - -
      - - -
      +
      + +
      }
      diff --git a/src/app/common/file-uploader/file-uploader.component.scss b/src/app/common/file-uploader/file-uploader.component.scss index 78eee3433f..b98befe31d 100644 --- a/src/app/common/file-uploader/file-uploader.component.scss +++ b/src/app/common/file-uploader/file-uploader.component.scss @@ -293,3 +293,158 @@ overflow-wrap: anywhere; } } + +// --- Uploading and outcome --- +// One centred column. The bar is the only thing that moves, so the eye has one +// place to be while the request is in flight. + +$upload-ease-out: cubic-bezier(0.23, 1, 0.32, 1); + +.upload-progress, +.upload-outcome { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + min-height: 280px; + padding: 32px 24px; + text-align: center; + animation: upload-panel-in 220ms $upload-ease-out both; +} + +.upload-progress__well, +.upload-outcome__well { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: var(--ot-radius-circle, 999px); + background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent); + color: var(--ot-color-primary); + + mat-icon { + width: 26px; + height: 26px; + font-size: 26px; + } +} + +// Breathing, not spinning. Opacity only, so nothing is laid out again. +.upload-progress__well { + animation: upload-breathe 1600ms ease-in-out infinite; +} + +.upload-progress__title, +.upload-outcome__title { + margin: 6px 0 0; + color: var(--ot-color-text); + font-size: 1.05rem; + font-weight: 620; +} + +.upload-progress__files, +.upload-outcome__detail { + max-width: 40ch; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.upload-progress__track { + position: relative; + overflow: hidden; + width: min(360px, 100%); + height: 8px; + margin-block-start: 8px; + border-radius: 999px; + background: var(--ot-color-surface-raised); +} + +.upload-progress__fill { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--ot-color-primary); + // Clipped rather than sized, so each progress event stays on the compositor. + clip-path: inset(0 calc(100% - var(--upload-progress, 0%)) 0 0); + transition: clip-path 260ms $upload-ease-out; +} + +.upload-progress__value { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +.upload-progress button, +.upload-outcome button { + margin-block-start: 10px; + transition: transform 140ms $upload-ease-out; + + &:active { + transform: scale(0.97); + } +} + +.upload-outcome__well--success { + background: color-mix(in srgb, var(--ot-color-success) 16%, transparent); + color: var(--ot-color-success); +} + +.upload-outcome__well--error { + background: color-mix(in srgb, var(--ot-color-error) 16%, transparent); + color: var(--ot-color-error); +} + +.upload-outcome__actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; +} + +@keyframes upload-panel-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes upload-breathe { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.55; + } +} + +@media (prefers-reduced-motion: reduce) { + .upload-progress, + .upload-outcome, + .upload-progress__well { + animation: none; + opacity: 1; + transform: none; + } + + .upload-progress__fill { + transition-duration: 1ms; + } + + .upload-progress button, + .upload-outcome button { + transition: none; + } +} diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index 20a7f96452..28a7bd547e 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -69,6 +69,12 @@ export class FileUploaderComponent implements OnInit, OnChanges { @Input() showName: boolean = true; @Input() asButton: boolean = false; @Input() singleDropZone: boolean = false; + /** + * Set false when the host shows its own confirmation after a successful upload, + * so the two do not play one after the other. A failure still reports here, + * because only this component knows how to retry it. + */ + @Input() showSuccessState: boolean = true; @Input() showUploadButton: boolean = true; @Input() resetAfterUpload: boolean = true; @@ -194,6 +200,36 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.updateReadyState(this.readyToUpload()); } + /** + * The summary column beside the drop zone is a second copy of the same state. + * It earns its place only when there is more than one file to keep track of; + * with one, the zone becomes the selected file in place instead. + */ + public get showSummaryColumn(): boolean { + return this.singleDropZone && this.uploadZones.length > 1; + } + + /** + * With the summary column the left side narrows to the next zone still waiting + * for a file. Without it, every zone stays on screen and each one turns into + * its own selected file, so nothing disappears when a file is chosen. + */ + public get renderedUploadZones(): UploadZone[] { + return this.showSummaryColumn ? this.shownUploadZones : this.uploadZones; + } + + /** The file being sent, or a count once there is more than one. */ + public get uploadingFileLabel(): string { + const named = this.uploadZones + .map((zone) => zone.model?.[0]?.name) + .filter((name): name is string => !!name); + + if (named.length === 0) { + return ''; + } + return named.length === 1 ? named[0] : `${named.length} files`; + } + readyToUpload(): boolean { return this.uploadZones.every((zone) => zone.model?.length); } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index 63fcc9af0e..03c1c0c6ec 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -3,13 +3,20 @@
      @if (celebration.particles) { - + } - +

      {{ celebration.headline }}

      {{ celebration.detail }}

      - +
      } @else {
      @@ -123,23 +130,18 @@

      Team contribution

      }
      - @if (!uploadStarted) { - @if (submissionType === 'need_help') { -

      - Upload the required files so your tutor can help with this task, even if they are not - finished yet. -

      - } @else { -

      - Upload files that show your evidence of work until each required file has been - selected. -

      - } + @if (!uploadStarted && submissionType === 'need_help') { +

      + Upload the required files so your tutor can help with this task, even if they are not + finished yet. +

      } + @@ -154,6 +156,7 @@

      Team contribution

      [onSuccess]="onUploadSuccess" [payload]="payload" [resetAfterUpload]="false" + [showSuccessState]="false" [showUploadButton]="false" [singleDropZone]="true" [url]="uploadUrl" @@ -211,12 +214,17 @@

      Declaration

      + diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index 538e57ae02..ac3a943542 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -389,24 +389,21 @@ // --- Confirmation --- // Replaces the whole dialog once the submission lands, so the last thing the // student sees is the result rather than the form they just finished with. +// A student reaches this a handful of times a trimester, so it is allowed to +// take its time: the mark draws at nearly twice the usual pace, the words +// arrive after it lands, and the panel holds long enough to be read. + +$done-ease-out: cubic-bezier(0.23, 1, 0.32, 1); .submission-done { display: flex; flex-direction: column; align-items: center; - gap: 10px; - padding: 44px 24px 36px; + gap: 8px; + min-height: 340px; + padding: 56px 24px 44px; text-align: center; - animation: submission-done-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; - - button { - margin-block-start: 10px; - transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); - - &:active { - transform: scale(0.97); - } - } + animation: submission-done-in 260ms $done-ease-out both; } .submission-done__mark { @@ -415,31 +412,44 @@ } .submission-done__headline { - margin: 6px 0 0; + max-width: 20ch; + margin: 20px 0 0; color: var(--ot-color-text); - font-size: 1.05rem; - font-weight: 620; - line-height: 1.35; - // Waits for the tick to finish drawing, so the two do not compete. - animation: submission-done-rise 200ms cubic-bezier(0.23, 1, 0.32, 1) both; - animation-delay: 420ms; + font-size: clamp(1.5rem, 4.5vw, 2rem); + font-weight: 680; + line-height: 1.2; + letter-spacing: -0.02em; + // Waits for the tick to land, so the two do not compete for the same beat. + animation: submission-done-rise 320ms $done-ease-out both; + animation-delay: 900ms; } .submission-done__detail { - max-width: 34ch; + max-width: 36ch; margin: 0; color: var(--ot-color-text-muted); - font-size: 0.9rem; - line-height: 1.45; + font-size: 0.95rem; + line-height: 1.5; overflow-wrap: anywhere; - animation: submission-done-rise 200ms cubic-bezier(0.23, 1, 0.32, 1) both; - animation-delay: 480ms; + animation: submission-done-rise 320ms $done-ease-out both; + animation-delay: 1040ms; +} + +.submission-done__dismiss { + margin-block-start: 22px; + transition: transform 140ms $done-ease-out; + animation: submission-done-rise 280ms $done-ease-out both; + animation-delay: 1320ms; + + &:active { + transform: scale(0.97); + } } @keyframes submission-done-in { from { opacity: 0; - transform: scale(0.97); + transform: scale(0.98); } to { @@ -451,7 +461,7 @@ @keyframes submission-done-rise { from { opacity: 0; - transform: translateY(6px); + transform: translateY(10px); } to { @@ -463,13 +473,14 @@ @media (prefers-reduced-motion: reduce) { .submission-done, .submission-done__headline, - .submission-done__detail { + .submission-done__detail, + .submission-done__dismiss { animation: none; opacity: 1; transform: none; } - .submission-done button { + .submission-done__dismiss { transition: none; } } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 03bfc19596..29666c937e 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -120,7 +120,12 @@ describe('UploadSubmissionModalComponent', () => { expect(component.celebration?.headline).toBe('Submitted on time. Ready for feedback'); expect(dialogRef.close).not.toHaveBeenCalled(); + // Still open well after the sequence itself has finished arriving, so there + // is time to read it rather than catch it. vi.advanceTimersByTime(2500); + expect(dialogRef.close).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1500); expect(dialogRef.close).toHaveBeenCalledWith({value: task}); vi.useRealTimers(); }); @@ -143,7 +148,7 @@ describe('UploadSubmissionModalComponent', () => { component.finishCelebration(); expect(dialogRef.close).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(2500); + vi.advanceTimersByTime(5000); expect(dialogRef.close).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -176,6 +181,16 @@ describe('UploadSubmissionModalComponent', () => { expect(component.continueHint).toBeNull(); }); + it('drops the requirements callout when one drop zone already says the same thing', () => { + expect(component.showUploadRequirements).toBe(false); + + task.definition.uploadRequirements = [ + {key: 'file0', name: 'Report', type: 'document'}, + {key: 'file1', name: 'Code', type: 'code'}, + ] as never; + expect(component.showUploadRequirements).toBe(true); + }); + it('shows a status icon only for submission types that are task statuses', () => { expect(component.statusFor('ready_for_feedback')).toBe('ready_for_feedback'); expect(component.statusFor('reupload_evidence')).toBeNull(); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index afa6b06578..610caf6fa2 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -18,9 +18,14 @@ import {FileUploaderComponent} from 'src/app/common/file-uploader/file-uploader. import {AlertService} from 'src/app/common/services/alert.service'; import {EmojiService} from 'src/app/common/services/emoji.service'; import {PrivacyPolicy} from 'src/app/config/privacy-policy/privacy-policy'; +import {summariseUploadRequirement} from './task-upload-requirements/upload-category'; -/** Everything in the confirmation has settled by ~900ms, so this leaves a beat to read it. */ -const SUBMISSION_CELEBRATION_HOLD_MS = 1900; +/** + * The confirmation finishes arriving at about 1.6s. This leaves roughly two more + * seconds to take it in before the dialog closes itself, and the Done button is + * there for anyone who would rather not wait. + */ +const SUBMISSION_CELEBRATION_HOLD_MS = 3600; type UploadStage = 'group' | 'details' | 'comments'; type UploadSubmissionType = TaskStatusEnum | 'reupload_evidence' | 'test_submission'; @@ -187,6 +192,21 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { return 'Make a comment...'; } + /** + * The callout above the drop zones only earns its place when it says something + * they do not. A single requirement is already named, typed and stated as + * required by its own zone, so repeating it three ways just adds to the page. + */ + public get showUploadRequirements(): boolean { + const requirements = this.task.definition.uploadRequirements ?? []; + if (requirements.length !== 1) { + return requirements.length > 1; + } + + const summary = summariseUploadRequirement(requirements[0]); + return summary.hasMoreExtensions || summary.maxSizeLabel !== null; + } + /** The steps this submission goes through, in order. */ public get steps(): UploadSubmissionStep[] { const steps: UploadSubmissionStep[] = []; From 86e3cd968875ecda182b9a6ad43c97ec1f0977ea Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 14:40:33 +1000 Subject: [PATCH 58/88] fix(submit): keep the dialog steady between uploading and confirming --- .../file-uploader.component.html | 19 ++++++++---- .../file-uploader.component.scss | 21 +++++++++++-- .../file-uploader/file-uploader.component.ts | 17 +++++++++++ .../task-status-card.component.scss | 7 +++-- .../upload-submission-modal.component.html | 30 +++++++++++-------- .../upload-submission-modal.component.scss | 6 ++-- src/app/unit-hub/unit-hub.component.scss | 8 +++-- 7 files changed, 80 insertions(+), 28 deletions(-) diff --git a/src/app/common/file-uploader/file-uploader.component.html b/src/app/common/file-uploader/file-uploader.component.html index f7b1fcfc6e..0984bd90ab 100644 --- a/src/app/common/file-uploader/file-uploader.component.html +++ b/src/app/common/file-uploader/file-uploader.component.html @@ -176,14 +176,19 @@
      Selected files
      } @if (showUploader && readyToUpload() && isUploading) { - @if (!uploadingInfo?.complete) { - -
      + + @if (showProgressPanel) { +
      -

      Uploading your work

      +

      + {{ uploadSettling ? 'Uploaded' : 'Uploading your work' }} +

      {{ uploadingFileLabel }}

      Selected files

      {{ uploadingInfo?.progress ?? 0 }}%

      - + @if (!uploadSettling) { + + }
      } diff --git a/src/app/common/file-uploader/file-uploader.component.scss b/src/app/common/file-uploader/file-uploader.component.scss index b98befe31d..325d0608c0 100644 --- a/src/app/common/file-uploader/file-uploader.component.scss +++ b/src/app/common/file-uploader/file-uploader.component.scss @@ -306,8 +306,10 @@ $upload-ease-out: cubic-bezier(0.23, 1, 0.32, 1); flex-direction: column; align-items: center; gap: 10px; - min-height: 280px; - padding: 32px 24px; + // Matches the confirmation panel that replaces it, so the dialog does not + // resize between the two. + min-height: 360px; + padding: 40px 24px; text-align: center; animation: upload-panel-in 220ms $upload-ease-out both; } @@ -333,6 +335,21 @@ $upload-ease-out: cubic-bezier(0.23, 1, 0.32, 1); // Breathing, not spinning. Opacity only, so nothing is laid out again. .upload-progress__well { animation: upload-breathe 1600ms ease-in-out infinite; + transition: + background-color 200ms ease, + color 200ms ease; +} + +// Settles to the success colour while it waits to be replaced, so the handover +// to the confirmation reads as one movement rather than two screens. +.upload-progress--done .upload-progress__well { + background: color-mix(in srgb, var(--ot-color-success) 16%, transparent); + color: var(--ot-color-success); + animation: none; +} + +.upload-progress--done .upload-progress__fill { + background: var(--ot-color-success); } .upload-progress__title, diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index 28a7bd547e..b20a0b278e 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -218,6 +218,23 @@ export class FileUploaderComponent implements OnInit, OnChanges { return this.showSummaryColumn ? this.shownUploadZones : this.uploadZones; } + /** + * The request has landed but the host has not taken over yet. Completion fires + * `onComplete` on a short delay, and unmounting this panel the moment the bytes + * arrive left the dialog empty for that whole window. + */ + public get uploadSettling(): boolean { + return ( + !this.showSuccessState && + this.uploadingInfo?.complete === true && + this.uploadingInfo?.success === true + ); + } + + public get showProgressPanel(): boolean { + return !this.uploadingInfo?.complete || this.uploadSettling; + } + /** The file being sent, or a count once there is more than one. */ public get uploadingFileLabel(): string { const named = this.uploadZones diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss index cb5a81a99e..f0cd5e57d9 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss @@ -108,9 +108,12 @@ } } +// Size this through Material's own token. It derives the width, the height and +// the padding together, so the glyph stays in the middle of its hover circle. +// Setting width and height by hand leaves the padding at the 40px default and +// the icon sits up and to the left of the ripple. .task-status-actions > [mat-icon-button] { - width: 44px; - height: 44px; + --mat-icon-button-state-layer-size: 44px; flex: none; } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index 03c1c0c6ec..313a4a117e 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -19,19 +19,23 @@
      } @else { -
      -

      Submit task

      -
      - {{ task.definition.abbreviation }} - {{ task.definition.name }} - @if (dueDate) { - - - Due {{ dueDate | date: 'EEE d MMM' }} - - } -
      -
      + + @if (!uploadStarted) { +
      +

      Submit task

      +
      + {{ task.definition.abbreviation }} + {{ task.definition.name }} + @if (dueDate) { + + + Due {{ dueDate | date: 'EEE d MMM' }} + + } +
      +
      + }
      diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index ac3a943542..912faeb23e 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -400,8 +400,10 @@ $done-ease-out: cubic-bezier(0.23, 1, 0.32, 1); flex-direction: column; align-items: center; gap: 8px; - min-height: 340px; - padding: 56px 24px 44px; + // Matches the uploader's progress panel, so replacing one with the other does + // not resize the dialog. + min-height: 360px; + padding: 40px 24px; text-align: center; animation: submission-done-in 260ms $done-ease-out both; } diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index ec77f808e6..b41e26e004 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -847,11 +847,13 @@ fieldset { border-radius: var(--ot-radius-circle); background: currentColor; } +// Sized through Material's tokens, which derive the box and its padding together. +// Hardcoding width and height leaves the padding at the default and knocks the +// glyph off the centre of its hover circle. .card-meta .mark-read.mat-mdc-icon-button { - width: 36px; - height: 36px; + --mat-icon-button-state-layer-size: 36px; + --mat-icon-button-icon-size: 20px; margin-left: auto; - padding: 6px; border: 1px solid var(--hub-card-border); color: var(--ot-color-link); mat-icon { From c0baabd613bb328d6f7356a9975ebb94ad37958b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 14:42:38 +1000 Subject: [PATCH 59/88] feat(unit-hub): explain what each task status means --- src/app/unit-hub/status-guide.spec.ts | 45 +++++++ src/app/unit-hub/status-guide.ts | 81 +++++++++++ src/app/unit-hub/unit-hub.component.html | 44 ++++++ src/app/unit-hub/unit-hub.component.scss | 165 +++++++++++++++++++++++ src/app/unit-hub/unit-hub.component.ts | 9 ++ 5 files changed, 344 insertions(+) create mode 100644 src/app/unit-hub/status-guide.spec.ts create mode 100644 src/app/unit-hub/status-guide.ts diff --git a/src/app/unit-hub/status-guide.spec.ts b/src/app/unit-hub/status-guide.spec.ts new file mode 100644 index 0000000000..a95f81832e --- /dev/null +++ b/src/app/unit-hub/status-guide.spec.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from 'vitest'; +import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; +import {buildStatusGuide} from './status-guide'; + +describe('buildStatusGuide', () => { + const guide = buildStatusGuide(); + const rows = guide.flatMap((group) => group.rows); + + it('covers every status the app can show, once each', () => { + const listed = rows.map((row) => row.status).sort(); + const expected = [...TaskStatus.STATUS_KEYS].sort(); + + expect(listed).toEqual(expected); + expect(new Set(listed).size).toBe(listed.length); + }); + + it('takes its wording from the same source the task pages read', () => { + rows.forEach((row) => { + const data = TaskStatus.statusData(row.status); + expect(row.label).toBe(data.label); + expect(row.reason).toBe(data.help.reason); + expect(row.action).toBe(data.help.action); + }); + }); + + it('names a tone that matches a status token', () => { + rows.forEach((row) => { + expect(row.tone).toBe(TaskStatus.statusClass(row.status)); + expect(row.tone).not.toContain('_'); + }); + }); + + it('drops a status that has no help text rather than showing an empty row', () => { + const help = TaskStatus.HELP_DESCRIPTIONS; + const removed = help.get('complete'); + help.delete('complete' as TaskStatusEnum); + + try { + const withoutComplete = buildStatusGuide().flatMap((group) => group.rows); + expect(withoutComplete.map((row) => row.status)).not.toContain('complete'); + } finally { + help.set('complete' as TaskStatusEnum, removed); + } + }); +}); diff --git a/src/app/unit-hub/status-guide.ts b/src/app/unit-hub/status-guide.ts new file mode 100644 index 0000000000..1e2a348aa7 --- /dev/null +++ b/src/app/unit-hub/status-guide.ts @@ -0,0 +1,81 @@ +import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; + +export interface StatusGuideRow { + status: TaskStatusEnum; + /** Matches the --ot-status- token, so the pill takes the app's own colour. */ + tone: string; + icon: string; + label: string; + reason: string; + action: string; +} + +export interface StatusGuideGroup { + key: string; + title: string; + icon: string; + rows: StatusGuideRow[]; +} + +/** + * Grouped by where a task sits in its life, rather than by the order the API + * happens to list them. A student reading this wants to find their own status, + * and "mine is with my tutor" narrows fifteen rows down to seven. + */ +const GROUPS: {key: string; title: string; icon: string; statuses: TaskStatusEnum[]}[] = [ + { + key: 'yours', + title: 'While the task is yours', + icon: 'edit', + statuses: ['not_started', 'working_on_it', 'need_help'], + }, + { + key: 'tutor', + title: 'Once your tutor has it', + icon: 'rate_review', + statuses: [ + 'ready_for_feedback', + 'fix_and_resubmit', + 'redo', + 'attention_required', + 'discuss', + 'rediscuss', + 'demonstrate', + ], + }, + { + key: 'settled', + title: 'Signed off, or past the deadline', + icon: 'flag', + statuses: ['complete', 'assess_in_portfolio', 'feedback_exceeded', 'time_exceeded', 'fail'], + }, +]; + +/** + * Reads the same labels, icons and help text the task pages use, so this page + * cannot drift from what a status actually means elsewhere in the app. + */ +export function buildStatusGuide(): StatusGuideGroup[] { + return GROUPS.map((group) => ({ + key: group.key, + title: group.title, + icon: group.icon, + rows: group.statuses + .map((status) => { + const data = TaskStatus.statusData(status); + if (!data?.help) { + return null; + } + + return { + status, + tone: TaskStatus.statusClass(status), + icon: data.materialIcon ?? 'help', + label: data.label ?? status, + reason: data.help.reason, + action: data.help.action, + } satisfies StatusGuideRow; + }) + .filter((row): row is StatusGuideRow => row !== null), + })).filter((group) => group.rows.length > 0); +} diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index 9b37cb74a9..b5928a1572 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -1161,6 +1161,50 @@

      No sessions scheduled yet

      } @if (showFeed) { + +
      + + + @if (statusGuideOpen) { +
      + @for (group of statusGuide; track group.key) { +
      +

      + + {{ group.title }} +

      + +
        + @for (row of group.rows; track row.status) { +
      • + + + {{ row.label }} + +

        {{ row.reason }}

        +

        {{ row.action }}

        +
      • + } +
      +
      + } +
      + } +
      +
      - - @if (celebration.particles) { - + +@if (!uploadStarted) { +
      +

      Submit task

      +
      + {{ task.definition.abbreviation }} + {{ task.definition.name }} + @if (dueDate) { + + + Due {{ dueDate | date: 'EEE d MMM' }} + } - - -

      {{ celebration.headline }}

      -

      {{ celebration.detail }}

      - -
      -} @else { - - @if (!uploadStarted) { -
      -

      Submit task

      -
      - {{ task.definition.abbreviation }} - {{ task.definition.name }} - @if (dueDate) { - - - Due {{ dueDate | date: 'EEE d MMM' }} - - } -
      -
      - } +
      + +} - + + @if (celebration) { + +
      + + @if (celebration.particles) { + + } + + +

      {{ celebration.headline }}

      +

      {{ celebration.detail }}

      + +
      + } @else {
      -
      - Submit as - - - - @if (statusFor(submissionType); as status) { - - } @else { - - } - {{ selectedSubmissionTypeLabel }} - - - @for (option of submissionTypeOptions; track option.id) { - + + @if (!uploadStarted) { +
      + Submit as + + - @if (statusFor(option.id); as status) { + @if (statusFor(submissionType); as status) { Submit task > } @else { } - {{ option.label }} + {{ selectedSubmissionTypeLabel }} - - } - -
      + + @for (option of submissionTypeOptions; track option.id) { + + + @if (statusFor(option.id); as status) { + + } @else { + + } + {{ option.label }} + + + } +
      +
      + } @if (steps.length > 1 && !uploadStarted) {
        @@ -242,73 +247,67 @@

        Declaration

        } }
      -
      + } +
      - @if (!uploadStarted) { - - +@if (!uploadStarted && !celebration) { + + -
      - @if (continueHint; as hint) { - {{ hint }} - } +
      + @if (continueHint; as hint) { + {{ hint }} + } - @if (isDetailsStage && showGroupSection) { - - } + @if (isDetailsStage && showGroupSection) { + + } - @if (isCommentsStage) { - - } + @if (isCommentsStage) { + + } - @if (isGroupStage) { - - } + @if (isGroupStage) { + + } - @if (isDetailsStage && showCommentsSection) { - - } + @if (isDetailsStage && showCommentsSection) { + + } - @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { - - } -
      - - } + @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { + + } +
      +
      } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index 912faeb23e..d6e1fb6489 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -401,9 +401,9 @@ $done-ease-out: cubic-bezier(0.23, 1, 0.32, 1); align-items: center; gap: 8px; // Matches the uploader's progress panel, so replacing one with the other does - // not resize the dialog. + // not resize the dialog. The sides come from the dialog content itself. min-height: 360px; - padding: 40px 24px; + padding: 40px 0; text-align: center; animation: submission-done-in 260ms $done-ease-out both; } From fa030b2d8d1c6db779a555ff7f0fa28d4ddc4e19 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 14:51:37 +1000 Subject: [PATCH 61/88] feat(submit): carry one panel from uploading through to the confirmation --- .../file-uploader/file-uploader.component.ts | 18 +- .../upload-submission-modal.component.html | 427 ++++++++++-------- .../upload-submission-modal.component.scss | 158 ++++--- .../upload-submission-modal.component.spec.ts | 53 +++ .../upload-submission-modal.component.ts | 39 ++ 5 files changed, 442 insertions(+), 253 deletions(-) diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index b20a0b278e..5d2edcd518 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -75,6 +75,12 @@ export class FileUploaderComponent implements OnInit, OnChanges { * because only this component knows how to retry it. */ @Input() showSuccessState: boolean = true; + /** + * Set false when the host draws the in-flight state itself, so one panel can + * carry the upload all the way into its own confirmation instead of handing + * over to a second one. A failure still reports here. + */ + @Input() showProgressState: boolean = true; @Input() showUploadButton: boolean = true; @Input() resetAfterUpload: boolean = true; @@ -232,7 +238,17 @@ export class FileUploaderComponent implements OnInit, OnChanges { } public get showProgressPanel(): boolean { - return !this.uploadingInfo?.complete || this.uploadSettling; + return this.showProgressState && (!this.uploadingInfo?.complete || this.uploadSettling); + } + + /** Percent sent so far, for a host drawing the in-flight state itself. */ + public get uploadProgress(): number { + return this.uploadingInfo?.progress ?? 0; + } + + /** True once the bytes are away, whether or not the host has taken over. */ + public get uploadLanded(): boolean { + return this.uploadingInfo?.complete === true && this.uploadingInfo?.success === true; } /** The file being sent, or a count once there is more than one. */ diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index 2d10fe66a6..01dac96265 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -17,46 +17,100 @@

      Submit task

      } - @if (celebration) { - -
      - - @if (celebration.particles) { - + + @if (showSubmitFlow) { +
      + -

      {{ celebration.headline }}

      -

      {{ celebration.detail }}

      - + +
      + + @if (celebration) { + + } @else if (!flowLanded) { + + }
      - } @else { -
      - +
      + - @if (!uploadStarted) { -
      - Submit as - - + @if (!uploadStarted) { +
      + Submit as + + + + @if (statusFor(submissionType); as status) { + + } @else { + + } + {{ selectedSubmissionTypeLabel }} + + + @for (option of submissionTypeOptions; track option.id) { + - @if (statusFor(submissionType); as status) { + @if (statusFor(option.id); as status) { Submit task > } @else { } - {{ selectedSubmissionTypeLabel }} + {{ option.label }} - - @for (option of submissionTypeOptions; track option.id) { - - - @if (statusFor(option.id); as status) { - - } @else { - - } - {{ option.label }} - - - } - -
      - } - - @if (steps.length > 1 && !uploadStarted) { -
        - @for (step of steps; track step.stage) { -
      1. - - {{ step.label }} - @if ($index < currentStepIndex) { - (done) - } -
      2. + } -
      - } - @if (isGroupStage && !uploadStarted) { -
      -
      -

      Team contribution

      -

      - Please rate each team member's effort on {{ task.definition.name }}. -

      -

      - Click the same rating twice to record no contribution. -

      -
      + +
      + } - 1 && !uploadStarted) { +
        + @for (step of steps; track step.stage) { +
      1. - -
    - } - -
    - @if (!uploadStarted && submissionType === 'need_help') { + + {{ step.label }} + @if ($index < currentStepIndex) { + (done) + } + + } + + } + @if (isGroupStage && !uploadStarted) { +
    +
    +

    Team contribution

    - Upload the required files so your tutor can help with this task, even if they are not - finished yet. + Please rate each team member's effort on {{ task.definition.name }}.

    - } - - - +

    + Click the same rating twice to record no contribution. +

    +
    - - +
    + } - @if (!uploadStarted && isCommentsStage) { -
    -
    - @if (submissionType === 'need_help') { -

    What do you need help with?

    +
    + @if (!uploadStarted && submissionType === 'need_help') { +

    + Upload the required files so your tutor can help with this task, even if they are not + finished yet. +

    + } + + + + + + +
    + + @if (!uploadStarted && isCommentsStage) { +
    +
    + @if (submissionType === 'need_help') { +

    What do you need help with?

    +

    + Add a short comment so your tutor knows what help you need on this task. +

    + } @else { +

    Final comments

    + @if (task.definition.assessInPortfolioOnly) {

    - Add a short comment so your tutor knows what help you need on this task. + Please specify which areas of your submission you would like feedback on.

    } @else { -

    Final comments

    - @if (task.definition.assessInPortfolioOnly) { -

    - Please specify which areas of your submission you would like feedback on. -

    - } @else { -

    - Add an optional note for your tutor to read while assessing this submission. -

    - } +

    + Add an optional note for your tutor to read while assessing this submission. +

    } -
    + } +
    - - Comment - - + + Comment + + -
    - Character count: {{ comment.length }} - @if (requiresComment) { - (Min. {{ minCommentLength }}) - } -
    -
    +
    + Character count: {{ comment.length }} + @if (requiresComment) { + (Min. {{ minCommentLength }}) + } +
    +
    - @if (privacyPolicy.privacy) { -
    -
    -

    Declaration

    -

    {{ privacyPolicy.privacy }}

    -
    + @if (privacyPolicy.privacy) { +
    +
    +

    Declaration

    +

    {{ privacyPolicy.privacy }}

    +
    -
    - - + - @if (showPlagiarism) { -

    - {{ privacyPolicy.plagiarism }} -

    - } -
    -
    - } + @if (showPlagiarism) { +

    + {{ privacyPolicy.plagiarism }} +

    + } + +
    } - - } + } + @if (!uploadStarted && !celebration) { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index d6e1fb6489..73c45d705c 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -386,103 +386,147 @@ } } -// --- Confirmation --- -// Replaces the whole dialog once the submission lands, so the last thing the -// student sees is the result rather than the form they just finished with. -// A student reaches this a handful of times a trimester, so it is allowed to -// take its time: the mark draws at nearly twice the usual pace, the words -// arrive after it lands, and the panel holds long enough to be read. +// --- The submit panel --- +// One panel from pressing Submit to the confirmation. Every part below is the +// same element the whole way through: the circle grows and changes colour, the +// title grows into the headline, the bar fills and then folds away. Nothing is +// swapped for anything else, so there is no second screen to notice. -$done-ease-out: cubic-bezier(0.23, 1, 0.32, 1); +$flow-ease-out: cubic-bezier(0.23, 1, 0.32, 1); +$flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); -.submission-done { +.submit-flow { display: flex; flex-direction: column; align-items: center; gap: 8px; - // Matches the uploader's progress panel, so replacing one with the other does - // not resize the dialog. The sides come from the dialog content itself. min-height: 360px; padding: 40px 0; text-align: center; - animation: submission-done-in 260ms $done-ease-out both; } -.submission-done__mark { +.submit-flow__mark { position: relative; display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: var(--ot-radius-circle, 999px); + background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent); + color: var(--ot-color-primary); + transition: + width 420ms $flow-ease-in-out, + height 420ms $flow-ease-in-out, + background-color 300ms ease, + color 300ms ease; +} + +.submit-flow__glyph { + width: 28px; + height: 28px; + font-size: 28px; + // A blur through the swap, so the file glyph becomes the tick rather than two + // icons trading places. + transition: + opacity 200ms ease, + filter 200ms ease; +} + +.submit-flow--done .submit-flow__mark { + background: color-mix(in srgb, var(--ot-color-success) 16%, transparent); + color: var(--ot-color-success); +} + +// The circle opens out to hold the drawn mark, which is 88px. +.submit-flow--celebrating .submit-flow__mark { + width: 104px; + height: 104px; + background: transparent; +} + +.submit-flow__title { + margin: 14px 0 0; + color: var(--ot-color-text); + font-size: 1.05rem; + font-weight: 620; + line-height: 1.3; + transition: + font-size 320ms $flow-ease-out, + margin 320ms $flow-ease-out; } -.submission-done__headline { +.submit-flow--celebrating .submit-flow__title { max-width: 20ch; - margin: 20px 0 0; - color: var(--ot-color-text); + margin-block-start: 20px; font-size: clamp(1.5rem, 4.5vw, 2rem); font-weight: 680; line-height: 1.2; letter-spacing: -0.02em; - // Waits for the tick to land, so the two do not compete for the same beat. - animation: submission-done-rise 320ms $done-ease-out both; - animation-delay: 900ms; } -.submission-done__detail { +.submit-flow__detail { max-width: 36ch; margin: 0; color: var(--ot-color-text-muted); - font-size: 0.95rem; + font-size: 0.9rem; line-height: 1.5; overflow-wrap: anywhere; - animation: submission-done-rise 320ms $done-ease-out both; - animation-delay: 1040ms; } -.submission-done__dismiss { - margin-block-start: 22px; - transition: transform 140ms $done-ease-out; - animation: submission-done-rise 280ms $done-ease-out both; - animation-delay: 1320ms; +.submit-flow__track { + position: relative; + overflow: hidden; + width: min(360px, 100%); + height: 8px; + margin-block-start: 14px; + border-radius: 999px; + background: var(--ot-color-surface-raised); + // Folds away once the bar has nothing left to say, rather than vanishing. + transition: + height 280ms $flow-ease-out, + margin 280ms $flow-ease-out, + opacity 200ms ease; +} - &:active { - transform: scale(0.97); - } +.submit-flow__fill { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--ot-color-primary); + // Clipped rather than sized, so each progress event stays on the compositor. + clip-path: inset(0 calc(100% - var(--flow-progress, 0%)) 0 0); + transition: + clip-path 260ms $flow-ease-out, + background-color 300ms ease; } -@keyframes submission-done-in { - from { - opacity: 0; - transform: scale(0.98); - } +.submit-flow--done .submit-flow__fill { + background: var(--ot-color-success); +} - to { - opacity: 1; - transform: none; - } +.submit-flow--celebrating .submit-flow__track { + height: 0; + margin-block-start: 0; + opacity: 0; } -@keyframes submission-done-rise { - from { - opacity: 0; - transform: translateY(10px); - } +.submit-flow__action { + margin-block-start: 18px; + transition: transform 140ms $flow-ease-out; - to { - opacity: 1; - transform: none; + &:active { + transform: scale(0.97); } } @media (prefers-reduced-motion: reduce) { - .submission-done, - .submission-done__headline, - .submission-done__detail, - .submission-done__dismiss { - animation: none; - opacity: 1; - transform: none; - } - - .submission-done__dismiss { + .submit-flow__mark, + .submit-flow__glyph, + .submit-flow__title, + .submit-flow__track, + .submit-flow__fill, + .submit-flow__action { transition: none; } } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 29666c937e..8329e4168f 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -181,6 +181,59 @@ describe('UploadSubmissionModalComponent', () => { expect(component.continueHint).toBeNull(); }); + it('carries one panel from sending through to the confirmation', () => { + const uploader = { + uploadProgress: 40, + uploadLanded: false, + uploadingFileLabel: 'report.pdf', + uploadingInfo: {success: null}, + cancelUpload: vi.fn(), + }; + (component as unknown as {fileUploader: unknown}).fileUploader = uploader; + + // Nothing until Submit is pressed. + expect(component.showSubmitFlow).toBe(false); + + component.uploadStarted = true; + expect(component.showSubmitFlow).toBe(true); + expect(component.flowTitle).toBe('Uploading your work'); + expect(component.flowDetail).toBe('report.pdf'); + expect(component.flowProgress).toBe(40); + expect(component.flowLanded).toBe(false); + + // The bytes are away, but the confirmation has not arrived yet. + uploader.uploadLanded = true; + expect(component.flowLanded).toBe(true); + expect(component.flowTitle).toBe('Uploaded'); + + // The same panel becomes the confirmation. + component.celebration = { + timing: 'on_time', + resubmission: false, + tone: 'success', + particles: true, + headline: 'Submitted on time. Ready for feedback', + detail: '1.1P Hello World', + }; + expect(component.showSubmitFlow).toBe(true); + expect(component.flowTitle).toBe('Submitted on time. Ready for feedback'); + expect(component.flowDetail).toBe('1.1P Hello World'); + expect(component.flowProgress).toBe(100); + }); + + it('hands a failure back to the uploader rather than holding the panel', () => { + (component as unknown as {fileUploader: unknown}).fileUploader = { + uploadProgress: 100, + uploadLanded: false, + uploadingFileLabel: 'report.pdf', + uploadingInfo: {success: false}, + }; + component.uploadStarted = true; + + expect(component.uploadFailed).toBe(true); + expect(component.showSubmitFlow).toBe(false); + }); + it('drops the requirements callout when one drop zone already says the same thing', () => { expect(component.showUploadRequirements).toBe(false); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 610caf6fa2..40a7897e01 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -192,6 +192,45 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { return 'Make a comment...'; } + // ---- The submit panel ---- + // One panel carries the whole thing: the same circle, the same two lines of + // text and the same bar go from sending to sent to confirmed. Nothing is + // unmounted and remounted in between, so there is nothing to read as a new + // screen appearing. + + /** True from the moment Submit is pressed until the dialog closes. */ + public get showSubmitFlow(): boolean { + return this.uploadStarted && !this.uploadFailed; + } + + public get uploadFailed(): boolean { + return this.fileUploader?.uploadingInfo?.success === false; + } + + public get flowProgress(): number { + return this.celebration ? 100 : (this.fileUploader?.uploadProgress ?? 0); + } + + /** The bytes are away. The panel starts becoming the confirmation here. */ + public get flowLanded(): boolean { + return !!this.celebration || this.fileUploader?.uploadLanded === true; + } + + public get flowTitle(): string { + if (this.celebration) { + return this.celebration.headline; + } + return this.flowLanded ? 'Uploaded' : 'Uploading your work'; + } + + public get flowDetail(): string { + return this.celebration?.detail ?? this.fileUploader?.uploadingFileLabel ?? ''; + } + + public cancelUpload(): void { + this.fileUploader?.cancelUpload(); + } + /** * The callout above the drop zones only earns its place when it says something * they do not. A single requirement is already named, typed and stated as From 04033466ea6fb385452c0e6ebb1fdd5445d16b40 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 14:54:16 +1000 Subject: [PATCH 62/88] fix(submit): blur the old words away and let the confirmation be dismissed --- .../upload-submission-modal.component.scss | 33 +++++++++++- .../upload-submission-modal.component.spec.ts | 33 +++++++++++- .../upload-submission-modal.component.ts | 51 +++++++++++++++---- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index 73c45d705c..a98c994466 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -445,6 +445,22 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); background: transparent; } +// The words and the bar blur away before they are replaced, so the change reads +// as one thing becoming another rather than a cut between two. +.submit-flow--swapping { + .submit-flow__title, + .submit-flow__detail, + .submit-flow__glyph, + .submit-flow__action { + opacity: 0; + filter: blur(4px); + } + + .submit-flow__track { + opacity: 0; + } +} + .submit-flow__title { margin: 14px 0 0; color: var(--ot-color-text); @@ -453,7 +469,9 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); line-height: 1.3; transition: font-size 320ms $flow-ease-out, - margin 320ms $flow-ease-out; + margin 320ms $flow-ease-out, + opacity 200ms ease, + filter 200ms ease; } .submit-flow--celebrating .submit-flow__title { @@ -472,6 +490,9 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); font-size: 0.9rem; line-height: 1.5; overflow-wrap: anywhere; + transition: + opacity 200ms ease, + filter 200ms ease; } .submit-flow__track { @@ -513,7 +534,10 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); .submit-flow__action { margin-block-start: 18px; - transition: transform 140ms $flow-ease-out; + transition: + transform 140ms $flow-ease-out, + opacity 200ms ease, + filter 200ms ease; &:active { transform: scale(0.97); @@ -529,4 +553,9 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); .submit-flow__action { transition: none; } + + .submit-flow--swapping :is(.submit-flow__title, .submit-flow__detail, .submit-flow__glyph) { + opacity: 1; + filter: none; + } } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 8329e4168f..4d508322b4 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -116,7 +116,13 @@ describe('UploadSubmissionModalComponent', () => { component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); component.onUploadComplete(); - // The confirmation has taken the dialog over and nothing has closed yet. + // The old words blur away first, so the panel is mid-swap and not yet + // showing the confirmation. + expect(component.flowSwapping).toBe(true); + expect(component.celebration).toBeNull(); + + vi.advanceTimersByTime(300); + expect(component.flowSwapping).toBe(false); expect(component.celebration?.headline).toBe('Submitted on time. Ready for feedback'); expect(dialogRef.close).not.toHaveBeenCalled(); @@ -130,6 +136,30 @@ describe('UploadSubmissionModalComponent', () => { vi.useRealTimers(); }); + it('lets someone in a hurry leave on the confirmation without a warning', () => { + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + hasSelectedFiles: () => true, + }; + const confirm = vi.spyOn(window, 'confirm'); + + // Mid-upload the dialog holds on, files selected and request in flight. + expect(component.canClose()).toBe(false); + + // Once it is confirmed the work is saved, so the backdrop and Escape work. + component.celebration = { + timing: 'on_time', + resubmission: false, + tone: 'success', + particles: false, + headline: 'Submitted on time. Ready for feedback', + detail: '1.1P Hello World', + }; + expect(component.canClose()).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); + it('closes early when the confirmation is dismissed, and only once', () => { vi.useFakeTimers(); (task.processTaskStatusChange as unknown as Mock).mockReturnValue({ @@ -144,6 +174,7 @@ describe('UploadSubmissionModalComponent', () => { component.submissionType = 'ready_for_feedback'; component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); component.onUploadComplete(); + vi.advanceTimersByTime(300); component.finishCelebration(); expect(dialogRef.close).toHaveBeenCalledTimes(1); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 40a7897e01..9ad7f06ad0 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -27,6 +27,9 @@ import {summariseUploadRequirement} from './task-upload-requirements/upload-cate */ const SUBMISSION_CELEBRATION_HOLD_MS = 3600; +/** How long the old words stay blurred before they are replaced. */ +const FLOW_SWAP_MS = 220; + type UploadStage = 'group' | 'details' | 'comments'; type UploadSubmissionType = TaskStatusEnum | 'reupload_evidence' | 'test_submission'; @@ -113,10 +116,13 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { public uploadSubmitLocked = false; /** Set once the submission has landed. It takes over the dialog until it closes. */ public celebration: SubmissionCelebration | null = null; + /** The moment between the two sets of words, while the old ones blur away. */ + public flowSwapping = false; private uploadResponse: UploadSubmissionResponse | null = null; private startUpload?: () => void; private celebrationTimer: ReturnType | null = null; + private swapTimer: ReturnType | null = null; constructor( @Inject(MAT_DIALOG_DATA) public data: UploadSubmissionModalData, @@ -382,6 +388,12 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { }; public canClose(): boolean { + // Once the confirmation is up the work is already saved, so anyone in a hurry + // can click the backdrop or press Escape and go. Nothing to warn about, and + // the upload flag is still set, which would otherwise hold them here. + if (this.celebration) { + return true; + } if (this.isUploading) { return false; } @@ -503,27 +515,46 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { /** Hold long enough for the mark to draw and the words to be read, then leave. */ private showCelebration(celebration: SubmissionCelebration): void { - this.celebration = celebration; - this.celebrationTimer = setTimeout( - () => this.finishCelebration(), - prefersReducedMotion() ? 900 : SUBMISSION_CELEBRATION_HOLD_MS, - ); + if (prefersReducedMotion()) { + this.celebration = celebration; + this.celebrationTimer = setTimeout(() => this.finishCelebration(), 900); + return; + } + + // Blur the old words and the bar out first, then change them. Without the + // pause the text is simply replaced between two frames, which reads as a cut + // however well the box around it is transitioning. + this.flowSwapping = true; + this.swapTimer = setTimeout(() => { + this.swapTimer = null; + this.celebration = celebration; + this.flowSwapping = false; + this.celebrationTimer = setTimeout( + () => this.finishCelebration(), + SUBMISSION_CELEBRATION_HOLD_MS, + ); + }, FLOW_SWAP_MS); } /** Also the Done button, so nobody has to wait out the hold. */ public finishCelebration(): void { - if (this.celebrationTimer) { - clearTimeout(this.celebrationTimer); - this.celebrationTimer = null; - } + this.clearFlowTimers(); this.dialogRef.close({value: this.task}); } - public ngOnDestroy(): void { + private clearFlowTimers(): void { if (this.celebrationTimer) { clearTimeout(this.celebrationTimer); this.celebrationTimer = null; } + if (this.swapTimer) { + clearTimeout(this.swapTimer); + this.swapTimer = null; + } + } + + public ngOnDestroy(): void { + this.clearFlowTimers(); } public uploadButtonClicked(): void { From 7aea28a3e6fab19ad67de8c28847d66f530c3992 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 14:59:02 +1000 Subject: [PATCH 63/88] chore(motion): a page to compare six ways of confirming a submission --- src/app/app.routes.ts | 8 + .../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 | 42 ++ .../motion-demo/submit-motion.component.scss | 113 ++++ .../motion-demo/submit-motion.component.ts | 77 +++ 7 files changed, 925 insertions(+) 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 diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 59e5d9a134..5d6bd89718 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -92,6 +92,14 @@ export const routes: Routes = [ ), data: {pageTitle: 'Motion'}, }, + { + path: 'submit-motion', + loadComponent: () => + import('./common/celebrate/motion-demo/submit-motion.component').then( + (m) => m.SubmitMotionComponent, + ), + data: {pageTitle: 'Submitting'}, + }, { path: 'demo-controls', component: DemoControlsComponent, 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..6f1c83e243 --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.html @@ -0,0 +1,42 @@ +
    +
    +

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

    +
    + } +
    + +
    +

    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..6b0ba06107 --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.ts @@ -0,0 +1,77 @@ +import {ChangeDetectionStrategy, Component, type QueryList, ViewChildren} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +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; + + 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()); + } +} From 66f227046c8dcde6d80318c45502f7817fb031c5 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 15:03:32 +1000 Subject: [PATCH 64/88] feat(submit): collapse the bar into the mark when the submission lands --- .../upload-submission-modal.component.html | 20 +- .../upload-submission-modal.component.scss | 203 +++++++++++------- .../upload-submission-modal.component.spec.ts | 8 +- .../upload-submission-modal.component.ts | 16 +- 4 files changed, 150 insertions(+), 97 deletions(-) diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index 01dac96265..b087bc8b95 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -27,15 +27,21 @@

    Submit task

    [class.submit-flow--celebrating]="celebration" [class.submit-flow--done]="flowLanded" > +

    {{ flowTitle }}

    diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index a98c994466..d78c5a6a58 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -387,10 +387,11 @@ } // --- The submit panel --- -// One panel from pressing Submit to the confirmation. Every part below is the -// same element the whole way through: the circle grows and changes colour, the -// title grows into the headline, the bar fills and then folds away. Nothing is -// swapped for anything else, so there is no second screen to notice. +// One panel from pressing Submit to the confirmation, and every part below is +// the same element the whole way through. The handover is the "collapse" shape +// from /submit-motion: the bar draws in to nothing, the circle takes the hit and +// answers it, and the mark draws out of the circle. Bar and mark read as one +// object rather than two things trading places. $flow-ease-out: cubic-bezier(0.23, 1, 0.32, 1); $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); @@ -405,7 +406,16 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); text-align: center; } +// Holds the confirmation's full height from the very first frame, so the circle +// growing inside it never pushes the words below it down. .submit-flow__mark { + display: inline-flex; + align-items: center; + justify-content: center; + height: 112px; +} + +.submit-flow__ring { position: relative; display: inline-flex; align-items: center; @@ -416,8 +426,8 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent); color: var(--ot-color-primary); transition: - width 420ms $flow-ease-in-out, - height 420ms $flow-ease-in-out, + width 380ms $flow-ease-in-out, + height 380ms $flow-ease-in-out, background-color 300ms ease, color 300ms ease; } @@ -426,39 +436,7 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); width: 28px; height: 28px; font-size: 28px; - // A blur through the swap, so the file glyph becomes the tick rather than two - // icons trading places. - transition: - opacity 200ms ease, - filter 200ms ease; -} - -.submit-flow--done .submit-flow__mark { - background: color-mix(in srgb, var(--ot-color-success) 16%, transparent); - color: var(--ot-color-success); -} - -// The circle opens out to hold the drawn mark, which is 88px. -.submit-flow--celebrating .submit-flow__mark { - width: 104px; - height: 104px; - background: transparent; -} - -// The words and the bar blur away before they are replaced, so the change reads -// as one thing becoming another rather than a cut between two. -.submit-flow--swapping { - .submit-flow__title, - .submit-flow__detail, - .submit-flow__glyph, - .submit-flow__action { - opacity: 0; - filter: blur(4px); - } - - .submit-flow__track { - opacity: 0; - } + transition: opacity 180ms ease; } .submit-flow__title { @@ -467,20 +445,6 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); font-size: 1.05rem; font-weight: 620; line-height: 1.3; - transition: - font-size 320ms $flow-ease-out, - margin 320ms $flow-ease-out, - opacity 200ms ease, - filter 200ms ease; -} - -.submit-flow--celebrating .submit-flow__title { - max-width: 20ch; - margin-block-start: 20px; - font-size: clamp(1.5rem, 4.5vw, 2rem); - font-weight: 680; - line-height: 1.2; - letter-spacing: -0.02em; } .submit-flow__detail { @@ -490,9 +454,14 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); font-size: 0.9rem; line-height: 1.5; overflow-wrap: anywhere; +} + +.submit-flow__title, +.submit-flow__detail, +.submit-flow__action { transition: - opacity 200ms ease, - filter 200ms ease; + opacity 180ms ease, + transform 200ms $flow-ease-out; } .submit-flow__track { @@ -503,11 +472,13 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); margin-block-start: 14px; border-radius: 999px; background: var(--ot-color-surface-raised); - // Folds away once the bar has nothing left to say, rather than vanishing. + // Narrows into the middle first, then folds its own height away. It never just + // disappears: something vanishing is what reads as a new screen. transition: - height 280ms $flow-ease-out, - margin 280ms $flow-ease-out, - opacity 200ms ease; + width 300ms $flow-ease-in-out, + height 200ms $flow-ease-out 260ms, + margin 200ms $flow-ease-out 260ms, + opacity 160ms ease 280ms; } .submit-flow__fill { @@ -522,40 +493,112 @@ $flow-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); background-color 300ms ease; } -.submit-flow--done .submit-flow__fill { - background: var(--ot-color-success); -} - -.submit-flow--celebrating .submit-flow__track { - height: 0; - margin-block-start: 0; - opacity: 0; -} - .submit-flow__action { margin-block-start: 18px; - transition: - transform 140ms $flow-ease-out, - opacity 200ms ease, - filter 200ms ease; &:active { transform: scale(0.97); } } -@media (prefers-reduced-motion: reduce) { - .submit-flow__mark, +// The bytes are away. This lands before the confirmation is worked out, so the +// wait on the server is part of the sequence instead of dead time. +.submit-flow--done { + .submit-flow__ring { + background: color-mix(in srgb, var(--ot-color-success) 16%, transparent); + color: var(--ot-color-success); + } + + .submit-flow__fill { + background: var(--ot-color-success); + } +} + +// The handover itself. +.submit-flow--swapping { + .submit-flow__track { + width: 0; + } + + .submit-flow__ring { + animation: submit-flow-absorb 320ms $flow-ease-out 240ms both; + } + .submit-flow__glyph, .submit-flow__title, - .submit-flow__track, - .submit-flow__fill, + .submit-flow__detail, .submit-flow__action { - transition: none; + opacity: 0; + transform: translateY(6px); + } +} + +.submit-flow--celebrating { + .submit-flow__ring { + width: 104px; + height: 104px; + background: transparent; + } + + .submit-flow__track { + width: 0; + height: 0; + margin-block-start: 0; + opacity: 0; + } + + .submit-flow__title { + max-width: 20ch; + margin-block-start: 20px; + font-size: clamp(1.5rem, 4.5vw, 2rem); + font-weight: 680; + line-height: 1.2; + letter-spacing: -0.02em; + animation: submit-flow-in 300ms $flow-ease-out 160ms both; + } + + .submit-flow__detail { + animation: submit-flow-in 300ms $flow-ease-out 240ms both; + } + + .submit-flow__action { + animation: submit-flow-in 300ms $flow-ease-out 340ms both; + } +} + +@keyframes submit-flow-absorb { + 0% { + transform: scale(1); + } + + 45% { + transform: scale(1.07); + } + + 100% { + transform: scale(1); + } +} + +@keyframes submit-flow-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; } +} - .submit-flow--swapping :is(.submit-flow__title, .submit-flow__detail, .submit-flow__glyph) { +@media (prefers-reduced-motion: reduce) { + .submit-flow :is(.submit-flow__ring, .submit-flow__glyph, .submit-flow__title), + .submit-flow :is(.submit-flow__detail, .submit-flow__track, .submit-flow__fill), + .submit-flow__action { + transition: none; + animation: none; opacity: 1; - filter: none; + transform: none; } } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 4d508322b4..f9d523c507 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -116,12 +116,12 @@ describe('UploadSubmissionModalComponent', () => { component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); component.onUploadComplete(); - // The old words blur away first, so the panel is mid-swap and not yet - // showing the confirmation. + // The handover plays first, so the panel is mid-swap and not yet showing + // the confirmation. expect(component.flowSwapping).toBe(true); expect(component.celebration).toBeNull(); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(600); expect(component.flowSwapping).toBe(false); expect(component.celebration?.headline).toBe('Submitted on time. Ready for feedback'); expect(dialogRef.close).not.toHaveBeenCalled(); @@ -174,7 +174,7 @@ describe('UploadSubmissionModalComponent', () => { component.submissionType = 'ready_for_feedback'; component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); component.onUploadComplete(); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(600); component.finishCelebration(); expect(dialogRef.close).toHaveBeenCalledTimes(1); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 9ad7f06ad0..15ce7d769c 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -27,8 +27,12 @@ import {summariseUploadRequirement} from './task-upload-requirements/upload-cate */ const SUBMISSION_CELEBRATION_HOLD_MS = 3600; -/** How long the old words stay blurred before they are replaced. */ -const FLOW_SWAP_MS = 220; +/** + * The handover: long enough for the bar to draw into the middle (300ms) and the + * circle to answer it (from 240ms). Matches the "collapse" timing on + * /submit-motion, where the six candidates were compared. + */ +const FLOW_SWAP_MS = 480; type UploadStage = 'group' | 'details' | 'comments'; type UploadSubmissionType = TaskStatusEnum | 'reupload_evidence' | 'test_submission'; @@ -116,7 +120,7 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { public uploadSubmitLocked = false; /** Set once the submission has landed. It takes over the dialog until it closes. */ public celebration: SubmissionCelebration | null = null; - /** The moment between the two sets of words, while the old ones blur away. */ + /** The handover: the bar collapses and the circle absorbs it. */ public flowSwapping = false; private uploadResponse: UploadSubmissionResponse | null = null; @@ -521,9 +525,9 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { return; } - // Blur the old words and the bar out first, then change them. Without the - // pause the text is simply replaced between two frames, which reads as a cut - // however well the box around it is transitioning. + // Play the handover before swapping the words. Without the pause the text is + // replaced between two frames, which reads as a cut however well the box + // around it is transitioning. this.flowSwapping = true; this.swapTimer = setTimeout(() => { this.swapTimer = null; From 8093e8f2bb1eaf26879c3a63dfa4c5166467a3b6 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 15:06:45 +1000 Subject: [PATCH 65/88] feat(portfolio): mark the end of a unit when the portfolio is sent --- .../motion-demo/submit-motion.component.html | 18 ++ .../motion-demo/submit-motion.component.ts | 29 ++- .../portfolio-celebration.component.html | 35 +++ .../portfolio-celebration.component.scss | 213 ++++++++++++++++++ .../portfolio-celebration.component.ts | 62 +++++ .../portfolio-celebration.service.spec.ts | 73 ++++++ .../portfolio-celebration.service.ts | 96 ++++++++ .../portfolio-review-step.component.spec.ts | 3 + .../portfolio-review-step.component.ts | 6 + 9 files changed, 534 insertions(+), 1 deletion(-) 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 diff --git a/src/app/common/celebrate/motion-demo/submit-motion.component.html b/src/app/common/celebrate/motion-demo/submit-motion.component.html index 6f1c83e243..3835c2bd67 100644 --- a/src/app/common/celebrate/motion-demo/submit-motion.component.html +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.html @@ -30,6 +30,24 @@

    {{ card.name }}

    } +
    +

    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

    diff --git a/src/app/common/celebrate/motion-demo/submit-motion.component.ts b/src/app/common/celebrate/motion-demo/submit-motion.component.ts index 6b0ba06107..24838c77bc 100644 --- a/src/app/common/celebrate/motion-demo/submit-motion.component.ts +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.ts @@ -1,6 +1,13 @@ -import {ChangeDetectionStrategy, Component, type QueryList, ViewChildren} from '@angular/core'; +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'; @@ -26,6 +33,8 @@ interface VariantCard { export class SubmitMotionComponent { @ViewChildren(SubmitFlowLabComponent) private panels?: QueryList; + private readonly portfolio = inject(PortfolioCelebrationService); + public readonly reducedMotion = prefersReducedMotion(); public readonly variants: VariantCard[] = [ @@ -74,4 +83,22 @@ export class SubmitMotionComponent { 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..17742b1932 --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.component.scss @@ -0,0 +1,213 @@ +// 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 .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; + color: var(--ot-color-text); + text-align: center; +} + +// ---- The mark and the words ---- +.ot-portfolio__stage { + display: flex; + 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-direction: column; + gap: 16px; + 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; + width: 22px; + height: 22px; + color: var(--ot-color-success); + font-size: 22px; + } + + 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 { + width: 18px; + height: 18px; + font-size: 18px; + } +} + +// ---- Leaving ---- +// No timer. It waits, because this is the one moment worth staying on. +.ot-portfolio__actions.mat-mdc-dialog-actions { + justify-content: center; + padding: 0 24px 28px; + 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..436068e2cf --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.component.ts @@ -0,0 +1,62 @@ +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 {MatIconModule} from '@angular/material/icon'; +import {AnimatedCheckComponent} from './animated-check.component'; +import {CelebrationParticlesComponent} from './celebration-particles.component'; +import {prefersReducedMotion} from './reduced-motion'; + +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 { + public readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject>(MatDialogRef); + + public readonly reducedMotion = prefersReducedMotion(); + + /** 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/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.spec.ts b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.spec.ts index 544cf97baf..270d3243e0 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.spec.ts +++ b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.spec.ts @@ -6,9 +6,11 @@ import {PortfolioReviewStepComponent} from './portfolio-review-step.component'; describe('PortfolioReviewStepComponent', () => { let component: PortfolioReviewStepComponent; let downloadFileWithFeedback: ReturnType; + let celebrate: ReturnType; beforeEach(() => { downloadFileWithFeedback = vi.fn(); + celebrate = vi.fn(); component = new PortfolioReviewStepComponent( {ExternalName: new BehaviorSubject('OnTrack')} as never, {} as never, @@ -16,6 +18,7 @@ describe('PortfolioReviewStepComponent', () => { {} as never, {} as never, {downloadFileWithFeedback} as never, + {celebrate} as never, ); component.project = { id: 17, diff --git a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.ts b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.ts index 4952646889..e3d39ca083 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.ts +++ b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.component.ts @@ -4,6 +4,7 @@ import {Task} from 'src/app/api/models/task'; import {Unit} from 'src/app/api/models/unit'; import {ProjectService} from 'src/app/api/services/project.service'; import {TaskService} from 'src/app/api/services/task.service'; +import {PortfolioCelebrationService} from 'src/app/common/celebrate/portfolio-celebration.service'; import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; @@ -38,6 +39,7 @@ export class PortfolioReviewStepComponent implements OnInit { private alertService: AlertService, private confirmationModal: ConfirmationModalService, private fileDownloaderService: FileDownloaderService, + private portfolioCelebration: PortfolioCelebrationService, ) {} ngOnInit(): void { @@ -91,11 +93,15 @@ export class PortfolioReviewStepComponent implements OnInit { public createPortfolio(): void { this.project.compilePortfolio = !this.project.compilePortfolio; + // Read before the update, so the count is what the student actually sent. + const included = this.selectedTasks; this.projectService.update(this.project).subscribe({ next: () => { this.project.compilePortfolio = true; this.project.portfolioStatus = 0.5; + // The end of a whole unit. Everything else here gets a line of text. + this.portfolioCelebration.celebrate(this.project, included); }, error: (error) => { this.project.compilePortfolio = false; From dd7cd6ebe7b0aa530a48a188c2fcffe6d110edab Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 15:09:26 +1000 Subject: [PATCH 66/88] fix(portfolio): confetti over the dialog and a centred dismiss --- .../portfolio-celebration.component.scss | 16 ++++- .../portfolio-celebration.component.ts | 58 ++++++++++++++++++- src/app/common/services/confetti.service.ts | 18 +++++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/app/common/celebrate/portfolio-celebration.component.scss b/src/app/common/celebrate/portfolio-celebration.component.scss index 17742b1932..7e107e2ab7 100644 --- a/src/app/common/celebrate/portfolio-celebration.component.scss +++ b/src/app/common/celebrate/portfolio-celebration.component.scss @@ -5,6 +5,12 @@ $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); @@ -14,6 +20,10 @@ $portfolio-ease-out: cubic-bezier(0.23, 1, 0.32, 1); .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; } @@ -21,6 +31,7 @@ $portfolio-ease-out: cubic-bezier(0.23, 1, 0.32, 1); // ---- The mark and the words ---- .ot-portfolio__stage { display: flex; + flex: none; flex-direction: column; align-items: center; padding: 48px 24px 8px; @@ -73,8 +84,10 @@ $portfolio-ease-out: cubic-bezier(0.23, 1, 0.32, 1); // ---- 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; @@ -158,8 +171,9 @@ $portfolio-ease-out: cubic-bezier(0.23, 1, 0.32, 1); // ---- 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 28px; + padding: 0 24px max(28px, env(safe-area-inset-bottom, 0px)); animation: portfolio-rise 320ms $portfolio-ease-out both; animation-delay: 2300ms; diff --git a/src/app/common/celebrate/portfolio-celebration.component.ts b/src/app/common/celebrate/portfolio-celebration.component.ts index 436068e2cf..d2be5104fc 100644 --- a/src/app/common/celebrate/portfolio-celebration.component.ts +++ b/src/app/common/celebrate/portfolio-celebration.component.ts @@ -1,11 +1,25 @@ -import {ChangeDetectionStrategy, Component, ViewEncapsulation, inject} from '@angular/core'; +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; @@ -45,12 +59,52 @@ export interface PortfolioCelebrationData { templateUrl: './portfolio-celebration.component.html', styleUrl: './portfolio-celebration.component.scss', }) -export class PortfolioCelebrationComponent { +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`; diff --git a/src/app/common/services/confetti.service.ts b/src/app/common/services/confetti.service.ts index 92a51c8ad2..52b38b2b77 100644 --- a/src/app/common/services/confetti.service.ts +++ b/src/app/common/services/confetti.service.ts @@ -5,12 +5,24 @@ import {Injectable} from '@angular/core'; providedIn: 'root', }) export class ConfettiService { - public canon(x: number = 0, y: number = 0, angle = 210): void { + /** + * `options.zIndex` matters over a dialog: the canvas is appended to the body at + * z-index 100 by default, which is under the CDK overlay, so confetti fired + * from inside a dialog lands behind it. + */ + public canon( + x: number = 0, + y: number = 0, + angle = 210, + options: {zIndex?: number; particleCount?: number; spread?: number; scalar?: number} = {}, + ): void { confetti({ angle: angle, - spread: 80, - particleCount: 100, + spread: options.spread ?? 80, + particleCount: options.particleCount ?? 100, origin: {y: y, x: x}, + ...(options.zIndex === undefined ? {} : {zIndex: options.zIndex}), + ...(options.scalar === undefined ? {} : {scalar: options.scalar}), }); } } From acdf8a889b3b4e92afb2690c7644a63d84d375e3 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 15:11:18 +1000 Subject: [PATCH 67/88] feat(file-uploader): one panel that collapses from uploading into uploaded --- .../file-uploader.component.html | 91 +++++---- .../file-uploader.component.scss | 180 ++++++++++++------ .../file-uploader/file-uploader.component.ts | 17 +- 3 files changed, 195 insertions(+), 93 deletions(-) diff --git a/src/app/common/file-uploader/file-uploader.component.html b/src/app/common/file-uploader/file-uploader.component.html index 0984bd90ab..0a91c4c0cf 100644 --- a/src/app/common/file-uploader/file-uploader.component.html +++ b/src/app/common/file-uploader/file-uploader.component.html @@ -176,62 +176,75 @@
    Selected files
    } @if (showUploader && readyToUpload() && isUploading) { - + @if (showProgressPanel) { -
    -
    -
    -
    - @if (form.invalid && form.dirty) { -

    Fix the highlighted fields before saving.

    - } -

    - {{ saveMessage }} -

    -

    {{ saveError }}

    -
    + + @if (showActions(form.dirty)) { +
    +
    + @if (form.invalid && form.dirty) { +

    Fix the highlighted fields before saving.

    + } @else if (mode === 'edit' && form.dirty) { +

    You have unsaved changes.

    + } +

    + {{ saveMessage }} +

    +

    {{ saveError }}

    +
    - - @if (mode === 'create') { - - } - @if (mode === 'edit') { - @if (form.dirty || saving) { + @if (mode === 'create') { + + } + @if (mode === 'edit' && (form.dirty || saving)) { + - } @else { -

    - - All changes saved -

    } - } -
    +
    + } diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.scss b/src/app/common/edit-profile-form/edit-profile-form.component.scss index 5c569e5f0d..432429c20b 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.scss +++ b/src/app/common/edit-profile-form/edit-profile-form.component.scss @@ -181,8 +181,9 @@ } // ---- Save bar ---- -// Sticky at the foot of the content column. It sits in flow after the last card, so -// at the end of the page it never covers anything. +// Only on screen when there is something to act on, so it arrives as an answer to +// an edit rather than sitting there as chrome. Sticky at the foot of the content +// column, in flow after the last card, so at the end of the page it covers nothing. .profile-actions { position: sticky; bottom: 0; @@ -194,9 +195,15 @@ gap: 8px 12px; box-sizing: border-box; min-height: var(--profile-actions-height); - padding: 12px 20px max(12px, env(safe-area-inset-bottom, 0px)); - // no rule above it: the page ground already separates the bar from the last card - background: var(--ot-color-page); + margin-block-start: 4px; + padding: 12px 16px max(12px, env(safe-area-inset-bottom, 0px)); + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md); + // Raised, not a full-bleed band: it lines up with the cards above it rather + // than reading as a separate strip pinned to the window. + background: var(--ot-color-surface-raised); + box-shadow: var(--ot-elevation-1, 0 1px 2px rgb(0 0 0 / 12%)); + animation: profile-actions-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; @media (max-width: 599px) { padding-inline: 16px; @@ -237,9 +244,42 @@ color: var(--ot-color-success); } + &__pending { + color: var(--ot-color-text); + font-weight: 600; + } + &__validation, &__error { color: var(--ot-color-error); font-weight: 600; } } + +// Nothing left to do, just the confirmation on its way out. +.profile-actions--settled { + justify-content: flex-start; + + .profile-save-status__success { + color: var(--ot-color-success); + font-weight: 600; + } +} + +@keyframes profile-actions-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .profile-actions { + animation: none; + } +} diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts index c6184b4af4..d2c9a78815 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts @@ -454,7 +454,7 @@ describe('EditProfileFormComponent notifications page link', () => { }); }); -// The save bar swaps its action for a quiet status once nothing is left to save. +// The save bar only exists while there is something to act on. @Directive({selector: 'form', exportAs: 'ngForm', standalone: false}) class StubNgFormSaveBar { public invalid = false; @@ -495,21 +495,55 @@ describe('EditProfileFormComponent save bar and labels', () => { fixture.debugElement.children[0].injector.get(StubNgFormSaveBar); const text = (): string => fixture.nativeElement.textContent; - it('shows a quiet saved status instead of a disabled button when clean', () => { + it('shows no bar at all when there is nothing to act on', () => { + expect(fixture.nativeElement.querySelector('.profile-actions')).toBeNull(); expect(fixture.nativeElement.querySelector('button[type="submit"]')).toBeNull(); - expect(fixture.nativeElement.querySelector('.profile-actions__saved')?.textContent).toContain( - 'All changes saved', - ); + expect(text()).not.toContain('All changes saved'); }); - it('shows the save action once the form has changes', () => { + it('brings the bar in with both actions once the form has changes', () => { form().dirty = true; form().pristine = false; fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.profile-actions')).not.toBeNull(); + expect(text()).toContain('You have unsaved changes'); + const submit: HTMLButtonElement = fixture.nativeElement.querySelector('button[type="submit"]'); expect(submit.textContent).toContain('Save changes'); - expect(fixture.nativeElement.querySelector('.profile-actions__saved')).toBeNull(); + expect(text()).toContain('Discard'); + }); + + it('keeps the bar while a save is in flight and while the result is still showing', () => { + const component = fixture.componentInstance; + + component.saving = true; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.profile-actions')).not.toBeNull(); + + component.saving = false; + component.justSaved = true; + component.saveMessage = 'Profile saved.'; + fixture.detectChanges(); + expect(text()).toContain('Profile saved.'); + + // Once the confirmation has had its moment the bar goes with it. + component.justSaved = false; + component.saveMessage = ''; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.profile-actions')).toBeNull(); + }); + + it('puts every edited field back when the changes are discarded', () => { + const component = fixture.componentInstance; + const original = component.user.firstName; + + component.user.firstName = 'Edited'; + component.user.nickname = 'Edited too'; + component.discard(); + + expect(component.user.firstName).toBe(original); + expect(component.user.nickname).not.toBe('Edited too'); }); it('labels the name fields in sentence case', () => { diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.ts b/src/app/common/edit-profile-form/edit-profile-form.component.ts index 060c43a6e1..09fc7ec0a7 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.ts @@ -21,6 +21,9 @@ import {UserService} from 'src/app/api/services/user.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +/** How long the save confirmation stays before the bar leaves with it. */ +const SAVED_CONFIRMATION_MS = 2600; + @Component({ selector: 'f-edit-profile-form', templateUrl: './edit-profile-form.component.html', @@ -59,6 +62,8 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { public formPronouns = {pronouns: ''}; public saving = false; public saveMessage = ''; + /** Keeps the confirmation on screen for a moment after the bar would otherwise go. */ + public justSaved = false; public saveError = ''; public get customPronouns(): boolean { return this.formPronouns.pronouns === '__customPronouns'; @@ -91,6 +96,9 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { this.user.displayPeerProgress = true; } + // The values Discard puts back. Retaken after every successful save. + this.takeSnapshot(); + // The same for Unit Hub updates, which are on in the bell and off everywhere // else until the user opts in. this.user.receiveUnitHubNotifications ??= true; @@ -109,6 +117,10 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.pushSubscription?.unsubscribe(); + if (this.justSavedTimer) { + clearTimeout(this.justSavedTimer); + this.justSavedTimer = null; + } } /** @@ -274,6 +286,85 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { return this.constants.IsTiiEnabled.value; } + /** The fields this form edits, for taking a snapshot to discard back to. */ + private static readonly EDITED_FIELDS = [ + 'firstName', + 'lastName', + 'nickname', + 'email', + 'studentId', + 'pronouns', + 'username', + 'systemRole', + 'optInToResearch', + 'displayPeerProgress', + 'acceptedTiiEula', + ] as const; + + private savedSnapshot: Record = {}; + /** The pronouns select sits outside the user object, so it is snapshotted too. */ + private savedFormPronouns = ''; + private justSavedTimer: ReturnType | null = null; + + /** The user as it was when the form was last clean. */ + private takeSnapshot(): void { + const snapshot: Record = {}; + EditProfileFormComponent.EDITED_FIELDS.forEach((field) => { + snapshot[field] = (this.user as unknown as Record)[field]; + }); + this.savedSnapshot = snapshot; + this.savedFormPronouns = this.formPronouns.pronouns; + } + + /** + * In edit mode the bar earns its place only when it has something to say: an + * edit to save, a save in flight, a problem, or a confirmation that has not + * faded yet. The other modes always need their main action on screen. + * + * Dirtiness comes from the template's own form reference, so this does not + * depend on how the form directive happens to be resolved. + */ + public showActions(dirty: boolean): boolean { + if (this.mode !== 'edit') { + return true; + } + return dirty || this.saving || this.justSaved || !!this.saveError; + } + + /** Puts every edited field back to how it was when the form was last clean. */ + public discard(form?: NgForm): void { + if (this.saving) { + return; + } + + const target = this.user as unknown as Record; + Object.entries(this.savedSnapshot).forEach(([field, value]) => { + target[field] = value; + }); + + this.formPronouns.pronouns = this.savedFormPronouns; + this.saveMessage = ''; + this.saveError = ''; + form?.form.markAsPristine(); + form?.form.markAsUntouched(); + } + + private confirmSaved(message: string, form?: NgForm): void { + this.saveMessage = message; + form?.form.markAsPristine(); + this.takeSnapshot(); + + // The bar leaves on its own once the confirmation has been seen. + this.justSaved = true; + if (this.justSavedTimer) { + clearTimeout(this.justSavedTimer); + } + this.justSavedTimer = setTimeout(() => { + this.justSaved = false; + this.justSavedTimer = null; + }, SAVED_CONFIRMATION_MS); + } + public submit(form?: NgForm): void { if (this.saving || form?.invalid) { return; @@ -291,8 +382,7 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { this.saving = false; this.user = updatedUser; this.initialFirstName = this.user.firstName; - form?.form.markAsPristine(); - this.saveMessage = 'User created.'; + this.confirmSaved('User created.', form); this._snackBar.open('User created', 'dismiss', { duration: 1500, @@ -316,8 +406,7 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { } else { this.user = updatedUser; this.initialFirstName = this.user.firstName; - form?.form.markAsPristine(); - this.saveMessage = 'Profile saved.'; + this.confirmSaved('Profile saved.', form); // TODO: refactor into new alertService // this is a new snackbar alert test From dee5dd3ee8551e0469962380e16338f0a05c007a Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 16:02:09 +1000 Subject: [PATCH 70/88] fix(extension): render the extension reason as markdown --- .../extension-comment.component.html | 4 +- .../extension-comment.component.scss | 43 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html index 79a7157e8c..be955fc571 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html @@ -23,7 +23,9 @@
    Reason -

    {{ comment.text }}

    + +
    @if (comment.assessed) { diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss index 1362551759..8fb558b609 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss @@ -100,11 +100,46 @@ border-radius: var(--ot-radius-sm, 4px); background: color-mix(in srgb, var(--ot-color-text) 4%, transparent); - p { - margin: 0.125rem 0 0; - overflow-wrap: anywhere; - white-space: pre-line; + // Markdown output, so the pre-line whitespace rule that suited raw text is + // gone: marked already turns newlines into breaks. + .markdown-to-html { + margin-top: 0.125rem; font-size: 0.875rem; + overflow-wrap: anywhere; + + :first-child { + margin-top: 0; + } + + :last-child { + margin-bottom: 0; + } + + p { + margin: 0 0 0.5rem; + line-height: 1.5; + } + + ul, + ol { + margin: 0 0 0.5rem; + padding-inline-start: 1.25rem; + } + + li + li { + margin-top: 0.25rem; + } + + code { + padding: 0.0625rem 0.25rem; + border-radius: var(--ot-radius-xs, 3px); + background: color-mix(in srgb, var(--ot-color-text) 8%, transparent); + font-size: 0.9em; + } + + a { + color: var(--ot-color-link); + } } } From 0d7d984b37b6b8eef9634a32650295001a8b557b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 16:20:03 +1000 Subject: [PATCH 71/88] feat(extension): show an automatic extension as the app, not the tutor --- .../api/models/task-comment/task-comment.ts | 6 ++++++ src/app/api/services/task-comment.service.ts | 1 + .../extension-comment.component.html | 19 +++++++++++++++---- .../extension-comment.component.scss | 5 +++++ .../extension-comment.component.ts | 8 +++++++- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index 1508518b34..be3d9d5fb8 100644 --- a/src/app/api/models/task-comment/task-comment.ts +++ b/src/app/api/models/task-comment/task-comment.ts @@ -22,6 +22,12 @@ export class TaskComment extends Entity { recipientReadTime: string; commentType: string = 'text'; isNew: boolean; + /** + * True when OnTrack wrote this comment rather than a person. It is stored + * against the tutor for the task, because a comment needs an author, so + * without this it reads as something that tutor said. + */ + automated = false; replyToId: number; attachmentFileName?: string; attachmentMimeType?: string; diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts index c694ab41fb..cbe2fa1f75 100644 --- a/src/app/api/services/task-comment.service.ts +++ b/src/app/api/services/task-comment.service.ts @@ -75,6 +75,7 @@ export class TaskCommentService extends CachedEntityService { 'recipientReadTime', 'replyToId', 'isNew', + 'automated', 'attachmentFileName', 'attachmentMimeType', 'attachmentByteSize', diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html index be955fc571..59ef3748d3 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html @@ -4,16 +4,27 @@ [class.ext-card--decided]="comment.assessed" >
    - +
    - Extension request + {{ + comment.automated ? 'Extended automatically' : 'Extension request' + }} {{ comment.weeksRequested }} {{ comment.weeksRequested === 1 ? 'week' : 'weeks' }}
    + - {{ comment.author?.name }} + @if (comment.automated) { + By {{ externalName | async }} + } @else { + {{ comment.author?.name }} + } @if (comment.createdAt) { · {{ comment.createdAt | date: 'd MMM y' }} } @@ -22,7 +33,7 @@
    - Reason + {{ comment.automated ? 'Why' : 'Reason' }}
    diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss index 8fb558b609..adfcc54803 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.scss @@ -143,6 +143,11 @@ } } +// Automated extensions carry a byline instead of a person's name. +.ext-card__by-system { + font-weight: 600; +} + .ext-card__label { color: var(--ot-color-text-muted); font-size: 0.6875rem; diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts index 9f316be3fb..d96bf7f45f 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts @@ -2,6 +2,7 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; import {Task, TaskComment} from 'src/app/api/models/doubtfire-model'; import {ExtensionComment} from 'src/app/api/models/task-comment/extension-comment'; import {AlertService} from 'src/app/common/services/alert.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @Component({ selector: 'extension-comment', @@ -14,8 +15,13 @@ export class ExtensionCommentComponent { @Input() comment: ExtensionComment; @Input() task: Task; public busy = false; + /** What this deployment calls itself, for an automated extension's byline. */ + public readonly externalName = this.constants.ExternalName; - constructor(private alerts: AlertService) {} + constructor( + private alerts: AlertService, + private constants: DoubtfireConstants, + ) {} private handleError(error: {data: {error: string}}) { this.alerts.error('Error: ' + error.data.error, 6000); From 354be2ad55fe18f947762dacf9f078f6cae95e7f Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 16:32:01 +1000 Subject: [PATCH 72/88] fix(icons): keep a resized icon glyph centred in its own box --- src/app/unit-hub/unit-hub.component.scss | 11 +++++++++++ src/styles.scss | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index 0548cee6c0..ed95fc84e1 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -850,16 +850,27 @@ fieldset { // Sized through Material's tokens, which derive the box and its padding together. // Hardcoding width and height leaves the padding at the default and knocks the // glyph off the centre of its hover circle. +// Centred by the flex box rather than by Material's padding arithmetic. That +// padding is derived from the state-layer size alone, so the 1px border here is +// not accounted for and the glyph sits off-centre. mat-icon also keeps its 24px +// line-height when the font-size drops to 20, which pushes the glyph down again. .card-meta .mark-read.mat-mdc-icon-button { --mat-icon-button-state-layer-size: 36px; --mat-icon-button-icon-size: 20px; + display: inline-flex; + align-items: center; + justify-content: center; margin-left: auto; + padding: 0; border: 1px solid var(--hub-card-border); color: var(--ot-color-link); mat-icon { + flex: none; width: 20px; height: 20px; font-size: 20px; + line-height: 20px; + overflow: visible; } } @media (max-width: 599px) { diff --git a/src/styles.scss b/src/styles.scss index 8033ce6399..717f8faf57 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -193,6 +193,18 @@ body.split-pane-resizing pdf-viewer { background-color: color-mix(in srgb, var(--ot-color-primary) 11%, transparent); } +// Material ships mat-icon as font-size 24px, box 24px, line-height 24px. Any +// rule that shrinks the first two and leaves the third behind gets a glyph +// sitting low in its own box, which reads as a misaligned icon inside a round +// button. There are dozens of such rules across the app. +// +// line-height: 1 ties the line box to whatever font-size is in force, so a 24px +// icon is unchanged and every shrunken one lines up. Fixing it here beats +// adding a line-height to each of those rules and remembering it in the next. +.mat-icon { + line-height: 1; +} + // Filled buttons. Without a color input Material fills them with a neutral grey, // which in dark mode reads as a disabled box rather than the main action. Give // every filled button the primary fill and the same shape as outlined ones, so From 3dd484e9e13421e37cb2939aefdad4772ef9cb89 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 16:42:51 +1000 Subject: [PATCH 73/88] feat(dashboard): mark a task signed off while the student is watching --- .../task-status-card.component.html | 17 ++++-- .../task-status-card.component.scss | 12 +++++ .../task-status-card.component.ts | 54 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html index 17b78325f3..a0f7fc5e2f 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html @@ -14,10 +14,19 @@ > - + + @if (justSignedOff) { + + + + + } @else { + + }

    {{ task?.statusLabel() }}

    diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss index f0cd5e57d9..ec7003f76c 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss @@ -122,3 +122,15 @@ width: 100%; } } + +// The sign-off flourish sits where the status icon normally does, so the row +// does not change width when it plays. +.task-status-card__mark { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-right: 10px; +} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts index 7e996b12b1..b8f99b725a 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts @@ -14,12 +14,16 @@ import {TaskStatusEnum, TaskStatusUiData} from 'src/app/api/models/task-status'; import {UnitRole} from 'src/app/api/models/unit-role'; import {TaskService} from 'src/app/api/services/task.service'; import {UserService} from 'src/app/api/services/user.service'; +import {prefersReducedMotion} from 'src/app/common/celebrate/reduced-motion'; import {ExtensionModalService} from 'src/app/common/modals/extension-modal/extension-modal.service'; import {QrModalService} from 'src/app/common/modals/qr-modal/qr-modal.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {FeedbackAppealModalService} from 'src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.service'; import {SubmissionTypeModalService} from 'src/app/tasks/modals/submission-type-modal/submission-type-modal.service'; +/** How long the sign-off flourish stays before the card settles. */ +const SIGNED_OFF_MS = 2600; + @Component({ selector: 'f-task-status-card', templateUrl: './task-status-card.component.html', @@ -29,7 +33,16 @@ import {SubmissionTypeModalService} from 'src/app/tasks/modals/submission-type-m }) export class TaskStatusCardComponent implements OnChanges, OnDestroy { triggers: TaskStatusUiData[]; + /** + * Set when this task reaches Complete while the student is looking at it. The + * dialog on the dashboard only plays for work signed off since the last visit, + * so a change that lands mid-session would otherwise pass without a word. + */ + public justSignedOff = false; + private taskStatusSub: Subscription; + private signedOffTimer: ReturnType | null = null; + private lastSeenStatus?: TaskStatusEnum; constructor( private extensions: ExtensionModalService, @@ -43,6 +56,7 @@ export class TaskStatusCardComponent implements OnChanges, OnDestroy { ) { this.taskStatusSub = this.taskService.taskStatusUpdated$.subscribe((task) => { if (this.isCurrentTask(task)) { + this.noteStatusChange(task.status); this.reapplyTriggers(); } }); @@ -63,11 +77,51 @@ export class TaskStatusCardComponent implements OnChanges, OnDestroy { this.task = changes.task.currentValue; this.reapplyTriggers(); this.project = this.task.project; + // Switching tasks is not a transition. Seed the baseline so opening one + // that is already complete does not read as having just been signed off. + this.lastSeenStatus = this.task?.status; + this.clearSignedOffTimer(); + this.justSignedOff = false; } } ngOnDestroy(): void { this.taskStatusSub?.unsubscribe(); + this.clearSignedOffTimer(); + } + + /** + * Only the student whose work it is, only on the way into Complete, and only + * when the card already knew a different status. Arriving on a task that is + * already complete is the returning visit the dialog handles. + */ + private noteStatusChange(status: TaskStatusEnum): void { + const previous = this.lastSeenStatus; + this.lastSeenStatus = status; + + if (status !== 'complete' || previous === undefined || previous === 'complete') { + return; + } + if (this.task?.unit?.myRole !== 'Student') { + return; + } + + this.clearSignedOffTimer(); + this.justSignedOff = true; + this.signedOffTimer = setTimeout( + () => { + this.justSignedOff = false; + this.signedOffTimer = null; + }, + prefersReducedMotion() ? 900 : SIGNED_OFF_MS, + ); + } + + private clearSignedOffTimer(): void { + if (this.signedOffTimer) { + clearTimeout(this.signedOffTimer); + this.signedOffTimer = null; + } } private isCurrentTask(task: Task): boolean { From fc11169c86adc9734548a1f490585baa21750c42 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 22:10:03 +1000 Subject: [PATCH 74/88] feat(profile): choose how often the summary email arrives --- src/app/api/models/user/user.ts | 2 + src/app/api/services/user.service.ts | 1 + .../notification-settings.component.html | 26 ++++++++++ .../notification-settings.component.scss | 27 ++++++++++ .../notification-settings.component.spec.ts | 52 +++++++++++++++++-- .../notification-settings.component.ts | 17 ++++++ 6 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/app/api/models/user/user.ts b/src/app/api/models/user/user.ts index f46f286abe..ff95beb75c 100644 --- a/src/app/api/models/user/user.ts +++ b/src/app/api/models/user/user.ts @@ -24,6 +24,8 @@ export class User extends Entity { public receiveUnitHubEmailNotifications: boolean; public receiveUnitHubPushNotifications: boolean; public receiveUnitHubSessionReminders: boolean; + /** How often the unit summary email arrives: off, daily, weekly or monthly. */ + public digestFrequency: string; public displayPeerProgress: boolean; public themePreference: 'light' | 'dark' | 'system' | null; public themePreferenceUpdatedAt: string | null; diff --git a/src/app/api/services/user.service.ts b/src/app/api/services/user.service.ts index ce466914d3..a0e96d1acc 100644 --- a/src/app/api/services/user.service.ts +++ b/src/app/api/services/user.service.ts @@ -36,6 +36,7 @@ export class UserService extends CachedEntityService { 'receiveUnitHubEmailNotifications', 'receiveUnitHubPushNotifications', 'receiveUnitHubSessionReminders', + 'digestFrequency', 'displayPeerProgress', 'themePreference', 'themePreferenceUpdatedAt', diff --git a/src/app/common/notification-settings/notification-settings.component.html b/src/app/common/notification-settings/notification-settings.component.html index f036d9d07c..42bee89447 100644 --- a/src/app/common/notification-settings/notification-settings.component.html +++ b/src/app/common/notification-settings/notification-settings.component.html @@ -52,6 +52,32 @@

    + +
    + + + + @for (option of digestOptions; track option.value) { + {{ option.label }} + } + + + + {{ digestHelp }} + +
    +
    { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NotificationSettingsComponent], - imports: [FormsModule, MatCheckboxModule, MatIconModule, RouterModule.forRoot([])], + imports: [ + FormsModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + NoopAnimationsModule, + RouterModule.forRoot([]), + ], }).compileComponents(); fixture = TestBed.createComponent(NotificationSettingsComponent); @@ -61,7 +72,9 @@ describe('NotificationSettingsComponent', () => { it('renders the Unit Hub row like the other categories', () => { const rows: HTMLElement[] = Array.from( - fixture.nativeElement.querySelectorAll('.notification-setting'), + fixture.nativeElement.querySelectorAll( + '.notification-setting:not(.notification-setting--choice)', + ), ); const hubRow = rows[3]; @@ -152,7 +165,9 @@ describe('NotificationSettingsComponent', () => { const inputs = fixture.nativeElement.querySelectorAll( '.notification-setting > mat-checkbox input[type="checkbox"]', ); - const descriptions = fixture.nativeElement.querySelectorAll('.notification-setting small[id]'); + const descriptions = fixture.nativeElement.querySelectorAll( + '.notification-setting:not(.notification-setting--choice) small[id]', + ); const descriptionIds = [ 'task-notification-description', 'feedback-notification-description', @@ -186,4 +201,35 @@ describe('NotificationSettingsComponent', () => { expect(fixture.nativeElement.querySelector('a[href="/notifications"]')).toBeNull(); expect(fixture.nativeElement.textContent).not.toContain('Notifications page'); }); + + it('offers a cadence for the summary email and explains the one chosen', () => { + const component = fixture.componentInstance; + + expect(component.digestOptions.map((option) => option.value)).toEqual([ + 'off', + 'daily', + 'weekly', + 'monthly', + ]); + + component.user.digestFrequency = 'monthly'; + expect(component.digestHelp).toBe('How the trimester is going so far.'); + + // Never is a real choice here, separate from feedback notifications. + component.user.digestFrequency = 'off'; + expect(component.digestHelp).toBe('No summary email.'); + }); + + it('describes the cadence control the way the checkboxes are described', () => { + const select = fixture.nativeElement.querySelector('mat-select'); + const help = fixture.nativeElement.querySelector('#digest-frequency-description'); + + expect(select?.getAttribute('aria-describedby')).toBe('digest-frequency-description'); + expect(help).not.toBeNull(); + }); + + it('says nothing rather than guessing when the cadence is unknown', () => { + fixture.componentInstance.user.digestFrequency = undefined as unknown as string; + expect(fixture.componentInstance.digestHelp).toBe(''); + }); }); diff --git a/src/app/common/notification-settings/notification-settings.component.ts b/src/app/common/notification-settings/notification-settings.component.ts index 734fec2c9c..022c09705c 100644 --- a/src/app/common/notification-settings/notification-settings.component.ts +++ b/src/app/common/notification-settings/notification-settings.component.ts @@ -16,4 +16,21 @@ export class NotificationSettingsComponent { * someone else, and on the first-login setup form. */ @Input() showNotificationsLink = false; + + /** + * The summary email's cadence. 'off' stops it without also turning off + * feedback notifications, which used to be the only switch it had. + */ + public readonly digestOptions: {value: string; label: string; help: string}[] = [ + {value: 'off', label: 'Never', help: 'No summary email.'}, + {value: 'daily', label: 'Daily', help: 'What is due next, every morning.'}, + {value: 'weekly', label: 'Weekly', help: 'Deadlines and how you are tracking.'}, + {value: 'monthly', label: 'Monthly', help: 'How the trimester is going so far.'}, + ]; + + public get digestHelp(): string { + return ( + this.digestOptions.find((option) => option.value === this.user?.digestFrequency)?.help ?? '' + ); + } } From 3102ba4a6c7774e35bbb4cf98a82e00fdc81d345 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 22:45:49 +1000 Subject: [PATCH 75/88] fix: remove dead animation code and correct the control contrast rule --- src/app/app.routes.ts | 2 + .../task-status-card.component.html | 17 ++---- .../task-status-card.component.scss | 12 ----- .../task-status-card.component.ts | 54 ------------------- .../upload-submission-modal.component.html | 1 + src/styles.scss | 30 ++++------- 6 files changed, 18 insertions(+), 98 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 5d6bd89718..0b10cdebf0 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -90,6 +90,7 @@ export const routes: Routes = [ import('./common/celebrate/motion-demo/motion-demo.component').then( (m) => m.MotionDemoComponent, ), + canActivate: [demoToolsGuard], data: {pageTitle: 'Motion'}, }, { @@ -98,6 +99,7 @@ export const routes: Routes = [ import('./common/celebrate/motion-demo/submit-motion.component').then( (m) => m.SubmitMotionComponent, ), + canActivate: [demoToolsGuard], data: {pageTitle: 'Submitting'}, }, { diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html index a0f7fc5e2f..17b78325f3 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html @@ -14,19 +14,10 @@ > - - @if (justSignedOff) { - - - - - } @else { - - } +

    {{ task?.statusLabel() }}

    diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss index ec7003f76c..f0cd5e57d9 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.scss @@ -122,15 +122,3 @@ width: 100%; } } - -// The sign-off flourish sits where the status icon normally does, so the row -// does not change width when it plays. -.task-status-card__mark { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - margin-right: 10px; -} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts index b8f99b725a..7e996b12b1 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts @@ -14,16 +14,12 @@ import {TaskStatusEnum, TaskStatusUiData} from 'src/app/api/models/task-status'; import {UnitRole} from 'src/app/api/models/unit-role'; import {TaskService} from 'src/app/api/services/task.service'; import {UserService} from 'src/app/api/services/user.service'; -import {prefersReducedMotion} from 'src/app/common/celebrate/reduced-motion'; import {ExtensionModalService} from 'src/app/common/modals/extension-modal/extension-modal.service'; import {QrModalService} from 'src/app/common/modals/qr-modal/qr-modal.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {FeedbackAppealModalService} from 'src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.service'; import {SubmissionTypeModalService} from 'src/app/tasks/modals/submission-type-modal/submission-type-modal.service'; -/** How long the sign-off flourish stays before the card settles. */ -const SIGNED_OFF_MS = 2600; - @Component({ selector: 'f-task-status-card', templateUrl: './task-status-card.component.html', @@ -33,16 +29,7 @@ const SIGNED_OFF_MS = 2600; }) export class TaskStatusCardComponent implements OnChanges, OnDestroy { triggers: TaskStatusUiData[]; - /** - * Set when this task reaches Complete while the student is looking at it. The - * dialog on the dashboard only plays for work signed off since the last visit, - * so a change that lands mid-session would otherwise pass without a word. - */ - public justSignedOff = false; - private taskStatusSub: Subscription; - private signedOffTimer: ReturnType | null = null; - private lastSeenStatus?: TaskStatusEnum; constructor( private extensions: ExtensionModalService, @@ -56,7 +43,6 @@ export class TaskStatusCardComponent implements OnChanges, OnDestroy { ) { this.taskStatusSub = this.taskService.taskStatusUpdated$.subscribe((task) => { if (this.isCurrentTask(task)) { - this.noteStatusChange(task.status); this.reapplyTriggers(); } }); @@ -77,51 +63,11 @@ export class TaskStatusCardComponent implements OnChanges, OnDestroy { this.task = changes.task.currentValue; this.reapplyTriggers(); this.project = this.task.project; - // Switching tasks is not a transition. Seed the baseline so opening one - // that is already complete does not read as having just been signed off. - this.lastSeenStatus = this.task?.status; - this.clearSignedOffTimer(); - this.justSignedOff = false; } } ngOnDestroy(): void { this.taskStatusSub?.unsubscribe(); - this.clearSignedOffTimer(); - } - - /** - * Only the student whose work it is, only on the way into Complete, and only - * when the card already knew a different status. Arriving on a task that is - * already complete is the returning visit the dialog handles. - */ - private noteStatusChange(status: TaskStatusEnum): void { - const previous = this.lastSeenStatus; - this.lastSeenStatus = status; - - if (status !== 'complete' || previous === undefined || previous === 'complete') { - return; - } - if (this.task?.unit?.myRole !== 'Student') { - return; - } - - this.clearSignedOffTimer(); - this.justSignedOff = true; - this.signedOffTimer = setTimeout( - () => { - this.justSignedOff = false; - this.signedOffTimer = null; - }, - prefersReducedMotion() ? 900 : SIGNED_OFF_MS, - ); - } - - private clearSignedOffTimer(): void { - if (this.signedOffTimer) { - clearTimeout(this.signedOffTimer); - this.signedOffTimer = null; - } } private isCurrentTask(task: Task): boolean { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index b087bc8b95..cea825640d 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -26,6 +26,7 @@

    Submit task

    role="status" [class.submit-flow--celebrating]="celebration" [class.submit-flow--done]="flowLanded" + [class.submit-flow--swapping]="flowSwapping" > diff --git a/src/styles.scss b/src/styles.scss index 717f8faf57..0fc558fd2b 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -193,18 +193,6 @@ body.split-pane-resizing pdf-viewer { background-color: color-mix(in srgb, var(--ot-color-primary) 11%, transparent); } -// Material ships mat-icon as font-size 24px, box 24px, line-height 24px. Any -// rule that shrinks the first two and leaves the third behind gets a glyph -// sitting low in its own box, which reads as a misaligned icon inside a round -// button. There are dozens of such rules across the app. -// -// line-height: 1 ties the line box to whatever font-size is in force, so a 24px -// icon is unchanged and every shrunken one lines up. Fixing it here beats -// adding a line-height to each of those rules and remembering it in the next. -.mat-icon { - line-height: 1; -} - // Filled buttons. Without a color input Material fills them with a neutral grey, // which in dark mode reads as a disabled box rather than the main action. Give // every filled button the primary fill and the same shape as outlined ones, so @@ -363,16 +351,20 @@ ngx-gantt-root { outline-offset: 2px; } -// Accessibility (1.4.11): the resting outline of a text field and an unchecked box -// is how a user finds the control. Material's defaults are 38% black, which sits at -// 2.7:1 on a white card, so point both at the control-border token (3:1 or better -// on every surface in both themes). The dark selector matches the dark Material -// theme include in theme.scss, which this file follows. +// Accessibility (1.4.11): the resting outline of a text field is how a user finds +// it, and Material's default is 38% black, which is 2.7:1 on a white card. +// +// Only the text field. An earlier version of this pointed the checkbox and radio +// tokens here too, on the assumption they shared that 38% default. They do not: +// both default to on-surface-variant, which is already better than this token on +// every surface, so overriding them LOWERED contrast rather than raising it. The +// radio line was dead as well, because Material sets that token on the element +// itself once a colour is applied and a :root rule can never win. +// +// The dark selector matches the dark Material theme include in theme.scss. :root, :root[data-ot-theme='dark'] { --mat-form-field-outlined-outline-color: var(--ot-color-control-border); - --mat-checkbox-unselected-icon-color: var(--ot-color-control-border); - --mat-radio-unselected-icon-color: var(--ot-color-control-border); } // Keep focus outlines inside menu panels so their edges do not clip them. From e62b1f7f7fba4ffac743e87abedbd60797d4396c Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 23:26:02 +1000 Subject: [PATCH 76/88] test: give the suite a working localStorage on newer Node Node 22.4 added its own localStorage behind --localstorage-file, so from Node 24 on, globalThis.localStorage exists as a getter that returns undefined. Vitest's jsdom environment skips any key already present on the global, so jsdom's real localStorage never gets copied across and every spec that touches it dies on the first line of its beforeEach. sessionStorage is copied normally, which is what made this look like a jsdom bug rather than a Node one. Locally that was 31 tests across panel-layout, pdf-viewer and tutor-discussion failing for a reason none of them had anything to do with. CI runs Node 22 without the flag and never saw it, so the suite meant two different things depending on where it ran. Define a store only when there is not one already, so CI is untouched. --- src/vitest-setup.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/vitest-setup.ts b/src/vitest-setup.ts index e2b9a2ab21..93e87c3ab3 100644 --- a/src/vitest-setup.ts +++ b/src/vitest-setup.ts @@ -8,3 +8,33 @@ vi.setConfig({ hookTimeout: 30_000, testTimeout: 30_000, }); + +// Node 22.4 added its own localStorage, gated behind --localstorage-file, so on +// Node 24 and up globalThis.localStorage exists as a getter that returns +// undefined. Vitest's jsdom environment skips any key already present on the +// global, so jsdom's real localStorage is never copied across and every spec +// that touches it fails on the first line. sessionStorage is unaffected, which +// is what makes the failure look like a jsdom bug rather than a Node one. +// +// CI is on Node 22 without the flag, so it never sees this. Give local runs a +// working store so the suite means the same thing on both. +if (typeof globalThis.localStorage === 'undefined') { + const store: Map = new Map(); + + const storage: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key) => (store.has(String(key)) ? store.get(String(key))! : null), + key: (index) => Array.from(store.keys())[index] ?? null, + removeItem: (key) => void store.delete(String(key)), + setItem: (key, value) => void store.set(String(key), String(value)), + }; + + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + writable: true, + }); +} From ff3b2148981b6ae8a0668f553ee3175f69958b75 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 23:26:12 +1000 Subject: [PATCH 77/88] fix(submission): keep the upload dialog usable once the request lands Three faults that all start from the same place: the uploader keeps isUploading set after the request settles, because the panels that report the outcome render under it. The dialog read that as "still uploading" and refused to close. After a failed upload the student could use Try again or Cancel inside the error panel, but Escape and the backdrop did nothing, and the dialog's own Submit was locked out too. Ask the uploader whether bytes are actually on the wire instead. The confirmation panel swapped Cancel upload for Done, and rendered neither for the second or so in between. That window is the only thing in the dialog anyone can focus, so a keyboard user lost their place in the middle of the one moment the panel is asking them to look at. One button throughout, changing its label. Done is therefore on screen from the moment the bytes land, which can be before the uploader reports completion. Leaving in that window closed the dialog without ever folding the response into the task. The status change now runs at most once and either path can be the one to run it, and leaving early hands the celebration to the dashboard rather than spending it on a dialog that is already closing. Also drops color="warn" from Cancel. It is a dismissal, not a destructive action, and nothing rendered it differently anyway. --- .../file-uploader/file-uploader.component.ts | 10 +++ .../upload-submission-modal.component.html | 33 +++---- .../upload-submission-modal.component.spec.ts | 53 ++++++++++++ .../upload-submission-modal.component.ts | 86 +++++++++++++------ 4 files changed, 137 insertions(+), 45 deletions(-) diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index 5ab4fc9f92..930b7358aa 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -266,6 +266,16 @@ export class FileUploaderComponent implements OnInit, OnChanges { return this.uploadingInfo?.complete === true && this.uploadingInfo?.success === true; } + /** + * Bytes actually on the wire. `isUploading` stays true after the request + * settles, because the panels that report the outcome are rendered under it, + * so a host asking "is there something here I would interrupt?" has to ask + * this instead. + */ + public get uploadInFlight(): boolean { + return this.isUploading && this.uploadingInfo?.complete !== true; + } + /** The file being sent, or a count once there is more than one. */ public get uploadingFileLabel(): string { const named = this.uploadZones diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index cea825640d..707ee1fd7d 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -59,25 +59,18 @@

    Submit task

    - @if (celebration) { - - } @else if (!flowLanded) { - - } + + } @@ -296,7 +289,7 @@

    Declaration

    @if (!uploadStarted && !celebration) { - diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index f9d523c507..1199268500 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -57,6 +57,7 @@ describe('UploadSubmissionModalComponent', () => { (component as unknown as {fileUploader: unknown}).fileUploader = { isUploading: false, + uploadInFlight: false, hasSelectedFiles: () => true, }; const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); @@ -73,6 +74,7 @@ describe('UploadSubmissionModalComponent', () => { it('cannot dismiss while the upload request is active', () => { (component as unknown as {fileUploader: unknown}).fileUploader = { isUploading: true, + uploadInFlight: true, hasSelectedFiles: () => true, }; const confirm = vi.spyOn(window, 'confirm'); @@ -82,6 +84,56 @@ describe('UploadSubmissionModalComponent', () => { confirm.mockRestore(); }); + it('can be dismissed and retried once a failed upload has settled', () => { + // The uploader keeps isUploading set after the request lands, because its + // own outcome panels render under it. Reading that as "still uploading" + // trapped the student on the error: no backdrop, no Escape, no retry. + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + uploadInFlight: false, + uploadingInfo: {complete: true, success: false}, + hasSelectedFiles: () => true, + }; + component.uploadStarted = true; + component.uploadSubmitLocked = false; + + expect(component.uploadFailed).toBe(true); + + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + expect(component.canClose()).toBe(true); + expect(confirm).toHaveBeenCalled(); + confirm.mockRestore(); + + const startUpload = vi.fn(); + component.onUploaderReady(startUpload); + component.uploadButtonClicked(); + expect(startUpload).toHaveBeenCalled(); + }); + + it('still records the status change when Done is pressed before the callback fires', () => { + // Done is on screen from the moment the bytes land, which is about a second + // before the uploader reports completion. Leaving in that window used to + // close the dialog without ever applying the response. + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + component.finishCelebration(); + + expect(task.updateFromJson).toHaveBeenCalled(); + // Unclaimed, so the dashboard gets to show what this dialog no longer will. + expect(task.processTaskStatusChange).toHaveBeenCalledWith( + 'ready_for_feedback', + expect.anything(), + true, + false, + ); + expect(dialogRef.close).toHaveBeenCalledWith({value: task}); + + // And the uploader's own callback must not apply it a second time. + component.onUploadComplete(); + expect(task.processTaskStatusChange).toHaveBeenCalledTimes(1); + }); + it('marks the task queued immediately and closes on the same task after upload', () => { component.submissionType = 'ready_for_feedback'; component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); @@ -139,6 +191,7 @@ describe('UploadSubmissionModalComponent', () => { it('lets someone in a hurry leave on the confirmation without a warning', () => { (component as unknown as {fileUploader: unknown}).fileUploader = { isUploading: true, + uploadInFlight: true, hasSelectedFiles: () => true, }; const confirm = vi.spyOn(window, 'confirm'); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 15ce7d769c..34406b22c7 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -124,6 +124,8 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { public flowSwapping = false; private uploadResponse: UploadSubmissionResponse | null = null; + /** Guards the status change against running twice, or not at all. */ + private completionApplied = false; private startUpload?: () => void; private celebrationTimer: ReturnType | null = null; private swapTimer: ReturnType | null = null; @@ -152,6 +154,16 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { return this.fileUploader?.isUploading ?? false; } + /** + * Bytes on the wire, as opposed to a request that has landed and is reporting + * its outcome. `isUploading` covers both, so using it to decide whether the + * dialog may close left it shut after a failed upload: the error panel offers + * Try again and Cancel, but Escape and the backdrop did nothing. + */ + public get uploadInFlight(): boolean { + return this.fileUploader?.uploadInFlight ?? false; + } + public get showGroupSection(): boolean { return this.submissionType === 'ready_for_feedback' && this.task.isGroupTask(); } @@ -398,7 +410,7 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { if (this.celebration) { return true; } - if (this.isUploading) { + if (this.uploadInFlight) { return false; } if (!this.isDirty) { @@ -489,34 +501,52 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { return; } - const response = this.uploadResponse; - - if (!this.data.isTestSubmission) { - const expectedStatus = - this.submissionType === 'need_help' || this.submissionType === 'ready_for_feedback' - ? this.submissionType - : response.status; - - this.task.updateFromJson(response, this.taskService.mapping); - // The dialog the student started this from is still the thing they are - // looking at, so the confirmation belongs here rather than over the page - // they are about to be returned to. - const celebration = this.task.processTaskStatusChange( - expectedStatus as TaskStatusEnum, - this.alertService, - true, - true, - ); + // The dialog the student started this from is still the thing they are + // looking at, so the confirmation belongs here rather than over the page + // they are about to be returned to. + const celebration = this.applyCompletion(true); - if (celebration) { - this.showCelebration(celebration); - return; - } + if (celebration) { + this.showCelebration(celebration); + return; } this.dialogRef.close({value: this.task}); }; + /** + * Fold the response into the task. Done can be pressed in the second or so + * between the bytes landing and this callback firing, so it runs at most once + * and either caller may be the one to run it. + * + * Leaving early passes `claimCelebration: false`, which hands the moment to + * the dashboard rather than spending it on a dialog that is already closing. + */ + private applyCompletion(claimCelebration: boolean): SubmissionCelebration | null { + if (this.completionApplied || !this.uploadResponse?.id) { + return null; + } + this.completionApplied = true; + + if (this.data.isTestSubmission) { + return null; + } + + const response = this.uploadResponse; + const expectedStatus = + this.submissionType === 'need_help' || this.submissionType === 'ready_for_feedback' + ? this.submissionType + : response.status; + + this.task.updateFromJson(response, this.taskService.mapping); + return this.task.processTaskStatusChange( + expectedStatus as TaskStatusEnum, + this.alertService, + true, + claimCelebration, + ); + } + /** Hold long enough for the mark to draw and the words to be read, then leave. */ private showCelebration(celebration: SubmissionCelebration): void { if (prefersReducedMotion()) { @@ -540,9 +570,14 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { }, FLOW_SWAP_MS); } - /** Also the Done button, so nobody has to wait out the hold. */ + /** + * Also the Done button, so nobody has to wait out the hold. Done is on screen + * from the moment the upload lands, which can be before the completion + * callback has run, so apply what it would have applied before leaving. + */ public finishCelebration(): void { this.clearFlowTimers(); + this.applyCompletion(false); this.dialogRef.close({value: this.task}); } @@ -562,7 +597,7 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { } public uploadButtonClicked(): void { - if (this.uploadSubmitLocked || this.isUploading) { + if (this.uploadSubmitLocked || this.uploadInFlight) { return; } @@ -595,6 +630,7 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { this.uploadStarted = false; this.uploadSubmitLocked = false; this.uploadResponse = null; + this.completionApplied = false; this.currentStage = this.showGroupSection ? 'group' : 'details'; } From 41a3ab03646a6ef541a63150676f3a851a0865d6 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 16 Sep 2026 23:26:23 +1000 Subject: [PATCH 78/88] fix(a11y): meet contrast on outlined buttons, and let textareas grow Seven templates ask a textarea to autosize and set a minimum of three rows. TextFieldModule was never imported, so cdkTextareaAutosize has always been an inert attribute and every one of them rendered at the browser default of two rows. The extension and feedback-review dialogs are the two a student meets. The outlined-button treatment mixes its outline from --ot-color-primary and --ot-color-border. Both are dark in the dark theme, so the outline landed on #385A8D: 1.60:1 against a card or dialog, where WCAG 1.4.11 asks 3:1 of a control edge, and 2.19:1 at its best anywhere in the app. --ot-color-link and --ot-color-control-border are the lighter pair the dark theme already defines for this, and give 3.66:1. The icon had the same problem against its own hover wash at 2.21:1. Light mode is unchanged to the byte, because there link equals primary and control-border equals border. The treatment also painted every icon in primary with no guard, so Delete portfolio had a blue trash icon. Warn now gets the same shape of treatment in its own colour, matching the filled rule that already excludes it. The label stays in the text colour: error over an error-tinted fill tops out near 4.3:1, and no mix of error and text clears 4.5:1 in both themes without going pink. The outline and the icon carry the warning, and only have to reach 3:1. Deny on an extension request wanted to be red and said so with a local border and label, but never set color="warn", so it drew a red outline over the primary-tinted fill every other outlined button gets. It says warn now and the global rules supply the whole set. Four dismissal buttons that claimed warn and are not destructive give it up. --- .../scorm-extension-modal.component.html | 2 +- .../spec-con-modal.component.html | 2 +- .../task-assessment-modal.component.html | 2 +- src/app/doubtfire-angular.module.ts | 6 ++ .../submission-files-modal.component.html | 2 +- .../upload-submission-modal.component.scss | 5 +- .../extension-comment.component.html | 1 + .../extension-comment.component.scss | 9 +-- src/styles.scss | 55 +++++++++++++++++-- 9 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html index 89b4341889..c429a7c851 100644 --- a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html @@ -30,7 +30,7 @@

    Extra attempt request

    - +
    - + } - +
    diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 0529fc16dc..e1e1a86bbb 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -25,6 +25,7 @@ import {environment} from 'src/environments/environment'; import {ClipboardModule} from '@angular/cdk/clipboard'; import {DragDropModule} from '@angular/cdk/drag-drop'; import {ScrollingModule} from '@angular/cdk/scrolling'; +import {TextFieldModule} from '@angular/cdk/text-field'; import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {APP_INITIALIZER, ErrorHandler, Injector, NgModule} from '@angular/core'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; @@ -841,6 +842,11 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { ClipboardModule, DragDropModule, ScrollingModule, + // Seven templates ask a textarea to grow with its content, and set a minimum + // of three rows while they are at it. Without this the directive is an inert + // attribute, so every one of them rendered at the browser default of two rows + // and never grew. + TextFieldModule, MatToolbarModule, MatSidenavModule, MatFormFieldModule, diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html index 9ac9a8bcaf..6541f15c84 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html @@ -16,7 +16,7 @@

    - +
    diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss index d78c5a6a58..bf4561c85c 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.scss @@ -323,10 +323,13 @@ var(--ot-color-error) 85%, var(--ot-color-text) ); + // Against the decorative border this outline is 2.21:1 on the dialog surface + // in dark, under the 3:1 WCAG asks of a control edge. The control token takes + // it to 3.50:1 and leaves light mode exactly where it was. --mat-button-outlined-outline-color: color-mix( in srgb, var(--ot-color-error) 40%, - var(--ot-color-border) + var(--ot-color-control-border) ); background-color: transparent; } diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html index 59ef3748d3..8224396ce2 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html @@ -57,6 +57,7 @@

    @if (dateNeedsAttention) { -

    Pick a date from {{ dateRangeText }}

    +

    {{ dateErrorText }}

    } @else if (extensionSummary) { { failure.component.extensionData.controls.extensionReason.setValue( 'A sufficiently detailed reason for the request', ); + // Submit refuses anything the button would refuse, so the date has to be + // picked here as it would be on screen. minDate is always in range. + pickDate(failure.component, failure.component.minDate); failure.component.submitApplication(); @@ -93,6 +96,7 @@ describe('ExtensionModalComponent', () => { success.component.extensionData.controls.extensionReason.setValue( 'A sufficiently detailed reason for the request', ); + pickDate(success.component, success.component.minDate); success.component.submitApplication(); expect(success.requestExtension).toHaveBeenCalled(); @@ -180,6 +184,35 @@ describe('ExtensionModalComponent presentation', () => { expect(component.canSubmit).toBe(true); }); + it('refuses a date typed past the final deadline instead of sending it', () => { + // The out-of-range check used to return true outright once there was no + // range, so a typed 1 Jan 2030 went out as a 173 week request. + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + const {component, requestExtension} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + component.extensionData.controls.extensionReason.setValue(REASON); + + pickDate(component, new Date(2030, 0, 1)); + expect(component.isDateInRange).toBe(false); + expect(component.dateNeedsAttention).toBe(true); + expect(component.canSubmit).toBe(false); + expect(component.dateErrorText).toBe( + 'The final deadline has passed, so only the earliest date can be requested', + ); + + component.submitApplication(); + expect(requestExtension).not.toHaveBeenCalled(); + }); + + it('never renders an error naming an empty date range', () => { + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + pickDate(component, null); + expect(component.dateNeedsAttention).toBe(true); + expect(component.dateRangeText).toBe(''); + expect(component.dateErrorText).not.toContain('Pick a date from '); + }); + it('keeps submit disabled until the reason and a date in range are both valid', () => { const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); @@ -292,3 +325,55 @@ describe('ExtensionModalComponent template', () => { expect(buttons.every((button) => button.disabled)).toBe(true); }); }); + +describe('ExtensionModalComponent template past the final deadline', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + + await TestBed.configureTestingModule({ + declarations: [ExtensionModalComponent], + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatDatepickerModule, + MatDialogModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], + providers: [ + provideNativeDateAdapter(), + {provide: MatDialogRef, useValue: {close: vi.fn()}}, + {provide: MAT_DIALOG_DATA, useValue: {task: buildTask(DUE, DEADLINE)}}, + {provide: LOCALE_ID, useValue: 'en-US'}, + {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}}, + {provide: TaskCommentService, useValue: {requestExtension: vi.fn(() => of({}))}}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExtensionModalComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('closes the date field rather than leaving it open over an empty range', () => { + const el = fixture.nativeElement as HTMLElement; + const input = el.querySelector('.ext-date input'); + const toggle = el.querySelector('mat-datepicker-toggle button'); + + expect(fixture.componentInstance.hasDateRange).toBe(false); + expect(input?.disabled).toBe(true); + // Disabling the input carries the picker and its toggle, so there is no way + // in through the calendar either. + expect(toggle?.disabled).toBe(true); + expect(el.querySelector('.ext-date mat-hint')?.textContent).toContain( + 'The final deadline has passed', + ); + }); +}); diff --git a/src/app/common/modals/extension-modal/extension-modal.component.ts b/src/app/common/modals/extension-modal/extension-modal.component.ts index 1b4ee6ae49..83b3e19087 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.ts @@ -7,6 +7,7 @@ import { format, isAfter, isBefore, + isSameDay, startOfDay, } from 'date-fns'; import {ChangeDetectionStrategy, Component, Inject, LOCALE_ID} from '@angular/core'; @@ -141,7 +142,11 @@ export class ExtensionModalComponent { public get isDateInRange(): boolean { if (!this.hasDateRange) { - return true; + // There is no range left to choose from, so the earliest date is the only + // one the request can carry. Returning true here instead turned every + // check off: a typed date of 1 Jan 2030 passed validation and went out as + // a 173 week extension. + return isSameDay(this.extensionDate, this.minDate); } if (this.minDate && isBefore(this.extensionDate, startOfDay(this.minDate))) { return false; @@ -157,6 +162,14 @@ export class ExtensionModalComponent { return this.dateEntryInvalid || (this.dateChanged && !this.isDateInRange); } + /** Why the date will not do. Empty when there is nothing to say. */ + public get dateErrorText(): string { + if (!this.hasDateRange) { + return 'The final deadline has passed, so only the earliest date can be requested'; + } + return `Pick a date from ${this.dateRangeText}`; + } + /** * The picked extension as it will be requested, e.g. "+1 week · Thu 17 Sep". * Requests are made in whole weeks, so the length is shown in weeks. @@ -205,7 +218,10 @@ export class ExtensionModalComponent { } submitApplication(): void { - if (this.submitting || this.extensionData.invalid) { + // Checks what the button checks, rather than only the reason. The date was + // guarded by the disabled state alone, so anything that reached this method + // with a bad date sent it. + if (!this.canSubmit) { this.extensionData.markAllAsTouched(); return; } From 0af76f027ed0be0796abb0a0e6c2c02de2d060bf Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 17 Sep 2026 00:02:48 +1000 Subject: [PATCH 81/88] fix(extension): make the past-deadline date an invariant, not a check Follow-up to the previous commit, from reviewing it. The date guard was enforced by a disabled field and a validity getter, so the value could still be written and every getter downstream had to keep catching it. addEvent now refuses the write outright once there is no range. extensionDuration can then only ever be derived from a date that was allowed, which also removes a mismatch between the guard comparing calendar days and the duration counting whole days. dateErrorText branched on hasDateRange while dateRangeText empties on a different condition. They disagree for a task with no deadline, where hasDateRange is true and the range text is empty, so the error read "Pick a date from ." there and the field rendered no hint at all. Both now ask about the text they are going to use. The two tests are rewritten around what actually has to hold: the typed date never reaches the request, and no message names a range that resolved to nothing. The guard keeps its own test, since it is what would stop a far-future date if the field is ever re-enabled. Also corrects the skeleton comment. It said the shared clock only lined up start times "which already line up": true within one loading state, not in general. The better argument is that a common phase was never visible, a percentage transform ties speed to width, so a 600px bar crossed at about 750px/s beside a 30px circle at about 37px/s. In phase, never in step. --- .../extension-modal.component.html | 3 ++ .../extension-modal.component.spec.ts | 34 +++++++++++++------ .../extension-modal.component.ts | 18 +++++++++- src/styles.scss | 12 ++++--- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/app/common/modals/extension-modal/extension-modal.component.html b/src/app/common/modals/extension-modal/extension-modal.component.html index 0990b08ef9..7940d3e922 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.html +++ b/src/app/common/modals/extension-modal/extension-modal.component.html @@ -79,6 +79,9 @@

    Request an extension

    Choose a date from {{ dateRangeText }} } @else if (!hasDateRange) { The final deadline has passed, so the earliest date is requested + } @else { + + Choose a date on or after {{ formatShortDate(minDate) }} } diff --git a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts index 536df69c63..b7e6c495f3 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts @@ -192,25 +192,37 @@ describe('ExtensionModalComponent presentation', () => { component.extensionData.controls.extensionReason.setValue(REASON); pickDate(component, new Date(2030, 0, 1)); - expect(component.isDateInRange).toBe(false); - expect(component.dateNeedsAttention).toBe(true); - expect(component.canSubmit).toBe(false); - expect(component.dateErrorText).toBe( - 'The final deadline has passed, so only the earliest date can be requested', - ); + + // The date never lands, so nothing downstream can be derived from it. + expect(component.extensionSummary).toBe('+4 weeks · Sun 4 Oct'); + expect(component.isDateInRange).toBe(true); + expect(component.dateNeedsAttention).toBe(false); component.submitApplication(); - expect(requestExtension).not.toHaveBeenCalled(); + expect(requestExtension).toHaveBeenCalledWith(REASON, 4, expect.anything()); }); - it('never renders an error naming an empty date range', () => { + it('holds the earliest date even if the guard is the only thing left', () => { + // Belt to the braces above: if the field is ever re-enabled, this is what + // stops a far-future date being requested. vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); - pickDate(component, null); - expect(component.dateNeedsAttention).toBe(true); + (component as unknown as {extensionDate: Date}).extensionDate = new Date(2030, 0, 1); + expect(component.isDateInRange).toBe(false); + expect(component.dateErrorText).toBe( + 'The final deadline has passed, so only the earliest date can be requested', + ); + }); + + it('never names a date range that resolved to nothing', () => { + // A task with no deadline leaves hasDateRange true and dateRangeText empty, + // which is the pair that used to render "Pick a date from ." and no hint. + const {component} = buildComponent(undefined, buildTask(DUE, null as unknown as Date)); + + expect(component.hasDateRange).toBe(true); expect(component.dateRangeText).toBe(''); - expect(component.dateErrorText).not.toContain('Pick a date from '); + expect(component.dateErrorText).toBe('Pick a date on or after Sat 12 Sep'); }); it('keeps submit disabled until the reason and a date in range are both valid', () => { diff --git a/src/app/common/modals/extension-modal/extension-modal.component.ts b/src/app/common/modals/extension-modal/extension-modal.component.ts index 83b3e19087..a56747d48f 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.ts @@ -83,6 +83,14 @@ export class ExtensionModalComponent { maxDate = this.data.task.localDeadlineDate(); // deadline, hard deadline extensionDate = new Date(this.minDate); addEvent(type: string, event: MatDatepickerInputEvent) { + // With no range left the request carries the earliest date, so there is + // nothing here to change. Refusing the write makes that an invariant rather + // than something the validity getters have to keep catching, and it means + // extensionDuration can only ever be derived from a date that was allowed. + if (!this.hasDateRange) { + return; + } + this.dateEntryInvalid = !event.value; if (event.value) { this.extensionDate = new Date(event.value); @@ -162,11 +170,19 @@ export class ExtensionModalComponent { return this.dateEntryInvalid || (this.dateChanged && !this.isDateInRange); } - /** Why the date will not do. Empty when there is nothing to say. */ + /** + * Why the date will not do. hasDateRange and dateRangeText do not empty on + * the same condition, so this has to ask about the text it is going to use + * rather than assume a range exists wherever hasDateRange is true. Naming a + * range that resolved to nothing is what produced "Pick a date from ." + */ public get dateErrorText(): string { if (!this.hasDateRange) { return 'The final deadline has passed, so only the earliest date can be requested'; } + if (!this.dateRangeText) { + return `Pick a date on or after ${this.formatShortDate(this.minDate)}`; + } return `Pick a date from ${this.dateRangeText}`; } diff --git a/src/styles.scss b/src/styles.scss index 74f3ccd254..e304f7f3b1 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -280,10 +280,14 @@ body.split-pane-resizing pdf-viewer { // This used to drive that from a single registered custom property animated on // :root behind a :has(). The property inherits, so its computed value changed on // every element in the document sixty times a second for as long as any skeleton -// was on screen, and the :has() put a whole-document invalidation on the root as -// well. Neither bought anything: the percentage sizing above is what keeps the -// placeholders in step, and the shared clock only lined up start times, which -// already line up because a loading state mounts its skeletons in one frame. +// was on screen, and only the handful of pseudo-elements below ever read it. +// +// What the shared clock bought was a common phase, and a common phase is not +// something anyone can see here: a percentage transform ties speed to width, so +// a 600px bar's sheen crosses at about 750px/s and a 30px circle's at about +// 37px/s. They were in phase and never in step. Within one loading state the +// placeholders mount together and so start together anyway. Two regions loading +// on separate flags can now drift apart, which is the one thing given up. @keyframes ot-skeleton-sheen { from { transform: translateX(-100%); From 3ef12a8dd87a9846e3f11804174cbbaf74418081 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 17 Sep 2026 00:37:02 +1000 Subject: [PATCH 82/88] fix(submission): let the dialog close itself, and stop the uploader outliving it canClose is wired as the dialog's closePredicate, and Material runs a closePredicate for programmatic closes as well as Escape and the backdrop. So it decides whether the dialog may close itself, which the previous commit did not account for. The effect was a student submitting "I need help", "New Evidence" or a test submission seeing the upload succeed and then being asked by the browser whether to discard the files. Answering Cancel, the natural answer to a prompt you believe is wrong, vetoed the close and kept them in the dialog. Once the server has the submission there is nothing to discard, so that is now the first thing the predicate says. The uploader reports completion on an 800ms timer it never cancelled and never had an ngOnDestroy to cancel it in. Closing the dialog inside that window left the timer to call back into a destroyed component, which applied the submission and claimed its celebration, so the dashboard suppressed the fallback and the student was told nothing at all. The timer is cancelled on destroy, and the dialog records the submission itself when it goes, leaving the celebration unclaimed for the dashboard. Its subscription to ExternalName, a BehaviorSubject on a root service that never completes, is unsubscribed in the same place. Without it every dialog that ever held an uploader stayed in memory for the session. Two more from the same review. The Submit button stayed locked after a failed upload because only the success path cleared it, so the uploader's own Try again was the only way back; the modal now listens to onFailure. And the shared in-flight panel said "Uploading your work" to a tutor importing an enrolment CSV, so the wording is an input. --- .../file-uploader/file-uploader.component.ts | 38 +++++++++++++--- ...tfolio-add-extra-files-step.component.html | 1 + ...earning-summary-report-step.component.html | 1 + .../upload-submission-modal.component.html | 1 + .../upload-submission-modal.component.spec.ts | 43 ++++++++++++++++++- .../upload-submission-modal.component.ts | 23 ++++++++-- 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index 930b7358aa..5ccdb07183 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -4,10 +4,12 @@ import { EventEmitter, Input, OnChanges, + OnDestroy, OnInit, Output, SimpleChanges, } from '@angular/core'; +import {Subscription} from 'rxjs'; import {UserService} from 'src/app/api/services/user.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {ACCEPTED_TYPES} from './file-upload-types'; @@ -50,7 +52,7 @@ interface UploadingInfo { changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class FileUploaderComponent implements OnInit, OnChanges { +export class FileUploaderComponent implements OnInit, OnChanges, OnDestroy { @Input() files: FileUploadSpec; @Input() url: string; @Input() method = 'POST'; @@ -83,6 +85,12 @@ export class FileUploaderComponent implements OnInit, OnChanges { @Input() showProgressState: boolean = true; @Input() showUploadButton: boolean = true; @Input() resetAfterUpload: boolean = true; + /** + * What the in-flight panel calls what is being sent. This component is shared + * with the CSV importers and the group-set editor, where "your work" is a + * tutor's enrolment file and belongs to nobody. + */ + @Input() uploadingLabel: string = 'Uploading'; @Input() initiateUpload?: () => void; @@ -110,6 +118,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { ) {} private externalName: string = 'OnTrack'; + private externalNameSub: Subscription | null = null; + private completionTimer: ReturnType | null = null; private activeRequest?: XMLHttpRequest; private uploadWasCancelled = false; @@ -125,11 +135,28 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.resetUploader(); - this.constants.ExternalName.subscribe((name) => { + this.externalNameSub = this.constants.ExternalName.subscribe((name) => { this.externalName = name; }); } + ngOnDestroy(): void { + // ExternalName is a BehaviorSubject on a root service, so it never + // completes. Left subscribed, every dialog that has ever held an uploader + // stays in memory for the session. + this.externalNameSub?.unsubscribe(); + this.externalNameSub = null; + + // The completion callback runs on a delay, and the host it calls back into + // may be gone by then: closing the dialog in that window had a destroyed + // component apply the submission and claim its confirmation, so the student + // was told nothing at all. + if (this.completionTimer) { + clearTimeout(this.completionTimer); + this.completionTimer = null; + } + } + ngOnChanges(changes: SimpleChanges): void { if (changes['files']) { this.createUploadZones(changes.files.currentValue); @@ -250,10 +277,10 @@ export class FileUploaderComponent implements OnInit, OnChanges { } public get uploadTitle(): string { - if (this.uploadComplete) { + if (this.uploadComplete || this.uploadSettling) { return 'Uploaded'; } - return this.uploadSettling ? 'Uploaded' : 'Uploading your work'; + return this.uploadingLabel; } /** Percent sent so far, for a host drawing the in-flight state itself. */ @@ -386,7 +413,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { if (xhr.status >= 200 && xhr.status < 300) { this.onSuccess?.(response); this.uploadingInfo.success = true; - setTimeout(() => { + this.completionTimer = setTimeout(() => { + this.completionTimer = null; this.onComplete?.(); if (this.resetAfterUpload) { this.resetUploader(); diff --git a/src/app/projects/states/portfolio/directives/portfolio-add-extra-files-step/portfolio-add-extra-files-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-add-extra-files-step/portfolio-add-extra-files-step.component.html index df626503ac..9d4175383c 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-add-extra-files-step/portfolio-add-extra-files-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-add-extra-files-step/portfolio-add-extra-files-step.component.html @@ -64,6 +64,7 @@

    Other files

    [onSuccess]="addNewFilesToPortfolio.bind(this)" [payload]="uploadFilePayload" [showName]="false" + [uploadingLabel]="'Uploading your work'" [url]="project.portfolioUrl()" > diff --git a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html index 6fa8db5960..ee2c2c77f2 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html @@ -75,6 +75,7 @@

    [files]="learningSummaryReportFileUploadData.type" [onSuccess]="addNewFile.bind(this)" [payload]="learningSummaryReportFileUploadData.payload" + [uploadingLabel]="'Uploading your work'" [url]="project.portfolioUrl()" > diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index 707ee1fd7d..833239fbcd 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -199,6 +199,7 @@

    Team contribution

    [onCancelUpload]="onUploadCancelled" [onClickFailureCancel]="cancel" [onComplete]="onUploadComplete" + [onFailure]="onUploadFailed" [onSuccess]="onUploadSuccess" [payload]="payload" [resetAfterUpload]="false" diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 1199268500..f4fccf526c 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -95,7 +95,9 @@ describe('UploadSubmissionModalComponent', () => { hasSelectedFiles: () => true, }; component.uploadStarted = true; - component.uploadSubmitLocked = false; + // What the uploader's onFailure callback does, rather than a hand-set: the + // Submit button stays locked without it and the retry below is unreachable. + component.onUploadFailed(); expect(component.uploadFailed).toBe(true); @@ -110,6 +112,45 @@ describe('UploadSubmissionModalComponent', () => { expect(startUpload).toHaveBeenCalled(); }); + it('never asks about discarding work the server has already taken', () => { + // canClose is the dialog's closePredicate, and Material runs it for + // programmatic closes too. Prompting here both lied to the student and + // vetoed the dialog closing itself: answering "Cancel" to "discard your + // files?" kept them in it. + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + uploadInFlight: false, + hasSelectedFiles: () => true, + }; + const confirm = vi.spyOn(window, 'confirm'); + + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + // The response is in, the confirmation has not been worked out yet. + expect(component.celebration).toBeNull(); + expect(component.canClose()).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); + + it('records the submission when the dialog is dismissed before the callback', () => { + // Escape or the backdrop on the "Uploaded" panel destroys the dialog before + // the uploader reports completion. The bytes are already accepted, so the + // status change still has to land, with the celebration left for elsewhere. + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + component.ngOnDestroy(); + + expect(task.updateFromJson).toHaveBeenCalled(); + expect(task.processTaskStatusChange).toHaveBeenCalledWith( + 'ready_for_feedback', + expect.anything(), + true, + false, + ); + }); + it('still records the status change when Done is pressed before the callback fires', () => { // Done is on screen from the moment the bytes land, which is about a second // before the uploader reports completion. Leaving in that window used to diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 34406b22c7..a0f163105c 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -404,10 +404,12 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { }; public canClose(): boolean { - // Once the confirmation is up the work is already saved, so anyone in a hurry - // can click the backdrop or press Escape and go. Nothing to warn about, and - // the upload flag is still set, which would otherwise hold them here. - if (this.celebration) { + // Material runs this for programmatic closes as well as Escape and the + // backdrop, so it decides whether the dialog may close itself. Once the + // server has taken the submission there is nothing left to discard, and a + // prompt here would both lie to the student and veto the dialog's own exit: + // answering "Cancel" to "discard your files?" would keep them in it. + if (this.celebration || this.uploadResponse) { return true; } if (this.uploadInFlight) { @@ -443,6 +445,13 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { this.currentStage = 'details'; }; + public onUploadFailed = (): void => { + // Only the success path cleared this, so after a failed upload the dialog's + // own Submit stayed locked for good and the uploader's Try again was the + // only way back. + this.uploadSubmitLocked = false; + }; + public onBeforeUpload = (): void => { Object.keys(this.payload).forEach((key) => delete this.payload[key]); @@ -594,6 +603,12 @@ export class UploadSubmissionModalComponent implements OnInit, OnDestroy { public ngOnDestroy(): void { this.clearFlowTimers(); + // The dialog can go before the uploader's completion callback arrives: + // Escape or the backdrop on the "Uploaded" panel, or Done during the + // handover. The submission is already on the server either way, so record + // it, and leave the celebration unclaimed for the dashboard to show. Does + // nothing when the response never arrived, or has already been applied. + this.applyCompletion(false); } public uploadButtonClicked(): void { From 8a5dec58b22dec2364bc6ba98e9af1de453b585b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 17 Sep 2026 00:38:38 +1000 Subject: [PATCH 83/88] fix(panels): end the previous drag when a second pointer grabs the handle startResize keeps a drag's teardown in one field and overwrote it without looking. A second pointerdown before the first pointerup therefore added a second pointermove/pointerup/pointercancel trio to the document and stranded the first one: the pointerup that followed removed only the newer set, and ngOnDestroy could only ever reach the latest closure either. The abandoned move handler keeps the dead drag's startX and startWidth, so the panel goes on resizing from a pointer with no button held, for the rest of the session, writing the hijacked width to storage on every later click. The button check above it does not help: touch reports button 0 for every finger, so a two-finger grab on the divider is enough. stopDragging is idempotent, so calling it first is the whole fix. The test fails without it. --- .../panel-layout.component.spec.ts | 29 +++++++++++++++++++ .../common/panel-layout/panel.component.ts | 7 +++++ 2 files changed, 36 insertions(+) diff --git a/src/app/common/panel-layout/panel-layout.component.spec.ts b/src/app/common/panel-layout/panel-layout.component.spec.ts index 8134c4da02..4eb97983b2 100644 --- a/src/app/common/panel-layout/panel-layout.component.spec.ts +++ b/src/app/common/panel-layout/panel-layout.component.spec.ts @@ -171,6 +171,35 @@ describe('PanelLayoutComponent', () => { expect(panel('list').style.minWidth).toBe('280px'); }); + it('does not strand a drag when a second pointer grabs the same handle', () => { + // The teardown for a drag lives in one field. A second pointerdown used to + // overwrite it, leaving the first drag's document listeners on the page for + // good, still resizing the panel from a pointer with no button held. Touch + // reports button 0 for every finger, so this is a two-finger grab. + create(); + const list = instance('list'); + const added: string[] = []; + const removed: string[] = []; + vi.spyOn(document, 'addEventListener').mockImplementation(((type: string) => { + added.push(type); + }) as never); + vi.spyOn(document, 'removeEventListener').mockImplementation(((type: string) => { + removed.push(type); + }) as never); + + const grab = () => new PointerEvent('pointerdown', {button: 0, clientX: 500}); + list.startResize(grab()); + list.startResize(grab()); + + // Whatever the second grab added, the first grab's listeners are gone too. + expect(added.filter((type) => type === 'pointermove').length).toBe(2); + expect(removed.filter((type) => type === 'pointermove').length).toBe(1); + expect(list.resizing).toBe(true); + + vi.mocked(document.addEventListener).mockRestore(); + vi.mocked(document.removeEventListener).mockRestore(); + }); + it('clamps a remembered width that is out of range when it loads', () => { window.localStorage.setItem('ontrack.panels.spec.list', '{"width":90}'); window.localStorage.setItem('ontrack.panels.spec.comments', '{"width":9000}'); diff --git a/src/app/common/panel-layout/panel.component.ts b/src/app/common/panel-layout/panel.component.ts index 04686eeddf..7b2b12f90e 100644 --- a/src/app/common/panel-layout/panel.component.ts +++ b/src/app/common/panel-layout/panel.component.ts @@ -219,6 +219,13 @@ export class PanelComponent implements PanelRegistration, OnInit, OnDestroy { if (!this.canResize || event.button !== 0) { return; } + // Only one drag owns the panel. The teardown for a drag lives in a single + // field, so a second pointerdown before the first pointerup used to + // overwrite it and strand three document listeners: the abandoned move + // handler kept its own startX and startWidth and went on resizing the panel + // from a pointer with no button held, for the rest of the session. Touch + // reports button 0 for every finger, so the guard above does not cover it. + this.stopDragging(); event.preventDefault(); const startX = event.clientX; const startWidth = this.currentWidth; From 0d338c32efbecfef359c57832510735f6bc4f473 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 17 Sep 2026 02:03:42 +1000 Subject: [PATCH 84/88] fix(a11y): give three unnamed or double-named controls a real accessible name The "Summary email" select had no accessible name at all. It was paired with a