diff --git a/docs/unit-hub.md b/docs/unit-hub.md new file mode 100644 index 0000000000..3300c17316 --- /dev/null +++ b/docs/unit-hub.md @@ -0,0 +1,95 @@ +# Unit Hub: announcements and teaching sessions + +Unit Hub gives students a single place to find announcements, HelpHubs, lectures, seminars and workshops for their active units. It is available at `/unit-hub`, through the home-page card and in the account menu. It uses the same responsive Angular application on a phone, tablet, desktop and installed OnTrack PWA. + +## Normal use + +The normal version reads the signed-in user's authorised `/api/unit_hub` response. A student enrolled in SIT111 does not receive SIT102 content. The unit selector only narrows the units returned by the server. A direct link such as `/unit-hub?unit=111` cannot add access to a unit. No content is fabricated when loading fails; the page shows a retry action. + +The feed displays published, unexpired announcements and a bounded window of published session occurrences. The page shows the server's window end and warns when the announcement list is truncated. Content is plain text. Original-post links and meeting links open in a separate tab and must use HTTPS. A Teams meeting can still require the student's university sign-in. + +Click anywhere on an announcement or session card to open its full details, including the text, dates and blank space. Original-post, Join and calendar actions remain separate controls. Keyboard users can activate the title or **Read full announcement** / **View full details** button; the title's focus indicator outlines the whole card. The dialog opens only records in the current authorised feed and clears its selected content when the route, unit, feed or demo mode changes. It supports keyboard focus, Escape and a visible close button. It does not create a public sharing URL. + +## Study essentials + +Unit Hub includes public university entry points under **Study essentials**. The home-page card links to Unit Hub. This fork explicitly selects `'deakin'` in `src/app/study-essentials/study-essentials.config.ts`, shared by development, production and the demo. Operators at another institution should set `studyEssentialsProfile` to `''` before building to hide the panel, or supply a reviewed profile. Institution membership is never inferred from an email address, unit code or product name. + +The panel links to DeakinSync, CloudDeakin, StudentConnect, timetable guidance, the library and Student Central. Each destination handles its own sign-in and permissions. The links append no account, enrolment or course data. Unit-specific meeting and course links remain in the scoped unit content. See the [Study essentials handover](../src/app/study-essentials/README.md) for the reviewed destinations and configuration. + +## Publishing content + +A staff member with the unit's `can_manage` permission sees **Manage updates**. Choose the unit, then **New announcement** or **New session**. Leave **Publish to this unit** unchecked to save a draft. Staff can edit drafts and published records, remove announcements, and cancel sessions. + +For a session, enter the local start and end times and the IANA time zone, such as `Australia/Melbourne`. Weekly sessions keep the specified local time across daylight saving changes. Choose a final repeat date within six months. Create a separate weekly schedule for each weekday: a Thursday and Friday HelpHub needs two schedules. Editing or cancelling a repeating record affects the entire series. A cancelled session remains visible with its joining and calendar-copy actions removed, so students can see the cancellation. + +Content can be published in OnTrack by the teaching team or imported through the companion API's optional Microsoft Teams announcement synchronisation. The university administrator must configure tenant-approved Microsoft Graph access and explicitly map each unit offering to its channel. Microsoft SSO alone does not enable this connection; students do not connect a separate personal account. It is off by default. + +When a unit's connection is configured, the management page explains that posts appear after successful updates. “Configured” describes the presence of settings, not proof of working tenant permissions or a successful import. Imported announcements are labelled **From Teams**, are read-only in OnTrack, and provide **Manage in Teams** through their original-post link. Staff should change them at their source. No timetable is inferred from announcement text. Session schedules remain staff-maintained in OnTrack. + +## Make a Teams meeting + +Assigned teaching staff can prepare a meeting below the session editor or from a session detail view. Enter the session title, dates and time zone, then use **Open Teams draft**. The draft carries the subject, description, resolved start/end instants and any optional attendee sign-in email addresses. The composer has a separate details field, so shortening an invitation leaves the unsaved OnTrack session intact. It does not automatically copy an existing joining or source URL into a new invitation. + +Opening the draft sends nothing. Review the invitation and press **Send** in Teams. Copy the newly created meeting's join URL back into the OnTrack session, then save/publish it. OnTrack does not obtain the new URL automatically. This flow does not require the optional Graph announcement connection; Microsoft may ask for a Teams sign-in. It follows Microsoft's [documented meeting-draft integration](https://learn.microsoft.com/en-us/power-apps/teams/integrate-calls-and-meetings). + +One occurrence is opened. For a weekly session, set the repeat pattern and final date in Teams before sending, and keep them aligned with OnTrack. Location is included in the description. The helper uses explicit UTC instants and bounds the URL to 8,000 characters and the local attendee list to 20 unique addresses. If the draft is too long, shorten its details or add more attendees in Teams; content is never silently truncated. The HTTPS link lets Microsoft offer its app or browser. If the scheduling form does not open on a phone, use a desktop browser or copy the displayed details into Teams. No invitation is sent by the test suite. + +## Calendar choices + +- **Add to Google Calendar** opens a draft for one occurrence. The user chooses whether to save it. +- **Download .ics** creates a copy of one occurrence for compatible calendar apps. +- **Calendar settings → Include unit HelpHubs, lectures and classes** opts the user into sessions in their existing private OnTrack calendar subscription. It is off by default. Existing unit exclusions also apply to sessions. + +One-off Google drafts and imported files do not receive schedule changes. Subscribed calendars receive changes when the calendar provider refreshes the feed; refresh timing is controlled by the provider. In Google Calendar, the **From URL** subscription setup is performed in a desktop browser. Once subscribed, the calendar is available on mobile too. Session times and joining links are shared with the selected calendar provider. Keep the subscription URL private. + +## Demonstration + +Use a non-production build with demo tools enabled and sign in to the guarded synthetic account in the isolated demo API. After authentication, `DemoScenarioRegistryService` requests `/api/demo/scenario`; only a successful contract makes **Demo controls** available. A build flag alone is insufficient. Ordinary accounts, an unconfigured API and production cannot activate the walkthrough. + +Open **Demo controls**, turn **Demo mode on**, then open **Unit Hub**. The switch updates the page without reloading or signing the user out. Its state belongs to the current scenario and signed-in user in the browser tab. Sign-out or an account change clears that state and the hosted meeting links. + +Once the guarded walkthrough is enabled, Unit Hub uses its separate browser-only sample of a fictional student enrolled only in SIT111. It shows two announcements, two HelpHubs and a lecture, including their clickable detail views. The raw fixture contains SIT102 records specifically to exercise filtering; these do not appear. The fixture includes no names, account details or copied posts from real students or staff. Its dates are fictional and generated relative to the browser's local date. + +Demo mode does not read the Unit Hub API and blocks staff reads and writes. Samples have no joining action unless the presenter explicitly configures a hosted link. In **Demo controls → Use your own HelpHub links**, add a real Teams joining URL for either or both HelpHubs, choose **Save demo links**, then reopen Unit Hub. **Join hosted demo** opens that real meeting with a notice that the sample schedule is fictional. The lecture has no fabricated joining URL. + +Hosted links accept only supported HTTPS joining paths on `teams.microsoft.com`. They stay in session storage for the current browser tab, are cleared by **Clear demo links**, sign-out or the end of the tab session, and are never written to the OnTrack API or committed to fixtures. Turning demo mode off hides them while retaining them for another walkthrough in the same tab. Production cannot use the hosted-link store. + +Google drafts and .ics downloads require an explicit user action and are labelled **DEMO**. When a host provides a joining link, the calendar copy includes that link with fictional dates and a demo notice. Opening a Google draft shares its chosen details with Google. Without a hosted link, no joining URL is included. Demo .ics filenames and event UIDs have their own namespace. Session-subscription changes remain disabled even when Calendar is opened from the account menu. + +The optional **Try the Teams meeting draft** section in Demo controls opens a real Teams invitation draft with a **DEMO** title and fictional details. It sends nothing automatically. Review the example date and attendees before deliberately pressing **Send** in Teams. No actual meeting URLs should appear in committed fixtures, environment examples, public screenshots or handover text. + +Turn demo mode off to return to the real authorised endpoint, including in development builds. The combined runtime has no quiet-data mask or HTTP masking interceptor. See the [demo guide](../src/app/demo/README.md) for local setup and removal notes. + +Production builds disable demo tools and cannot activate the Unit Hub fixtures. This release supports the installed web app, not a separately implemented native iOS or Android application. + +## Frontend and API contract + +| Operation | Endpoint | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Published content for the signed-in account | `GET /api/unit_hub` | +| Staff announcements including drafts | `GET /api/units/:unit_id/announcements` | +| Create / update / remove announcement | `POST /api/units/:unit_id/announcements`, `PUT /api/units/:unit_id/announcements/:id`, `DELETE /api/units/:unit_id/announcements/:id` | +| Staff sessions including drafts and cancelled series | `GET /api/units/:unit_id/sessions` | +| Create / update / cancel session | `POST /api/units/:unit_id/sessions`, `PUT /api/units/:unit_id/sessions/:id`, `DELETE /api/units/:unit_id/sessions/:id` | +| Read / change subscription preference | `GET /api/webcal`, `PUT /api/webcal` | + +Writes use `{announcement: {...}}`, `{session: {...}}` and the existing `{webcal: {...}}` wrappers. The calendar preference is `include_learning_sessions` on the wire and `includeLearningSessions` in the Angular model. Feed rows have `unit_id`; `units` provide the id, code, name, `can_manage`, and `teams_sync` (`configured` or `not_configured`). Announcements include `source_provider` (`manual` or `microsoft_teams`) and `managed_externally`; no tenant, client or channel identifiers are exposed. The public session feed contains occurrence dates, whereas staff editing uses the original series dates. See `src/app/unit-hub/unit-hub.models.ts` for the field types. + +The [companion API change](https://github.com/ontrack-features-t2-2026/doubtfire-api/pull/160) must be deployed and migrated before this frontend. The API is the authority for authentication, enrolment, role checks, draft visibility and unit access. The client adds defence in depth by removing rows outside the returned unit list, rejecting unsafe links, and cancelling obsolete subscriptions when the route, unit or demo mode changes. The service keeps no shared content cache. Authenticated content is not added to the service worker's data cache. + +Calendar exports use explicit UTC instants. Local-time entry rejects daylight-saving gaps and repeated times, preventing silent schedule movement. ICS text is escaped and folded at UTF-8 byte boundaries to prevent content from injecting calendar properties. + +## Verification + +Release checks include Angular template/type checking, the production build and deployment configuration verification. Targeted Vitest suites cover enrolled-unit filtering, no fallback data on failure, demo/live isolation, blocked demo writes, cancellation of in-flight operations, staff wrappers, route reachability, plain-text detail views and clearing stale dialogs. Calendar checks cover time-zone conversion, safe links, Google encoding, ICS injection/folding and separate demo event identities. Further checks cover imported-post ownership, Teams draft parameter encoding and form preservation, hosted-link validation and sign-out clearing, and the explicit Study essentials profile. Tests inspect generated links without joining or sending a real meeting. + +Run the relevant checks: + +```sh +npm run typecheck +npm run lint +npm run test:ci -- --include='src/app/unit-hub/**/*.spec.ts' --include='src/app/study-essentials/**/*.spec.ts' --include='src/app/common/modals/calendar-modal/calendar-modal.component.spec.ts' --include='src/app/demo/**/*.spec.ts' --include='src/app/common/header/header.component.spec.ts' --include='src/app/common/guards/spec/role-whitelist.guard.spec.ts' +npm run build -- --configuration production +``` + +On this macOS validation host, the native Angular disk-cache addon aborted during local production builds. `CI=1` disables that cache and avoids the native failure without changing application or production settings. Font inlining also needs network access to Google Fonts. Use Node 22.23.2 with `CI=1 NG_BUILD_MAX_WORKERS=2 NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128"` if the same host issue occurs. diff --git a/src/app/study-essentials/README.md b/src/app/study-essentials/README.md new file mode 100644 index 0000000000..3deeb05348 --- /dev/null +++ b/src/app/study-essentials/README.md @@ -0,0 +1,23 @@ +# Study essentials + +Import `StudyEssentialsComponent` into a standalone component's `imports` and use +``. It takes no inputs and makes no API requests. + +The shared deployment choice is `studyEssentialsProfile` in +`study-essentials.config.ts`. This Deakin fork sets it to `'deakin'`. Operators at +other institutions should set it to `''` before building to hide the panel, or +add a reviewed institution profile. Development, production and the demo all use +the same choice. Product names, emails and unit codes never select a profile. + +These are outbound public entry points. Each service controls its own sign-in +and permissions. No credentials, enrolment details, meeting links or course IDs +are appended. Unit-specific course and meeting links belong in scoped unit +content, never this global panel. + +The links were checked on 14 September 2026 using Deakin's official pages: + +- [Our systems](https://www.deakin.edu.au/students/new-students/new-domestic-students/digital-setup/our-systems) links to DeakinSync and StudentConnect. +- [CloudDeakin](https://www.deakin.edu.au/clouddeakin) links its Single SignOn button to the D2L base URL. +- [Study timetables](https://www.deakin.edu.au/students/study-support/study-timetables) maintains the current STAR destination and calendar guidance. The panel uses this stable page because direct STAR addresses alternate between odd and even years. +- [Library](https://www.deakin.edu.au/library) provides books, articles and unit readings. +- [Student Central](https://www.deakin.edu.au/students/help/student-central) provides course advice and student enquiries. diff --git a/src/app/study-essentials/study-essentials.component.html b/src/app/study-essentials/study-essentials.component.html new file mode 100644 index 0000000000..ffbeb172fd --- /dev/null +++ b/src/app/study-essentials/study-essentials.component.html @@ -0,0 +1,33 @@ +@if (links.length) { +
+
+
+

Study essentials

+

Your Deakin services, together.

+
+ Opens in a new tab +
+ +
+} diff --git a/src/app/study-essentials/study-essentials.component.scss b/src/app/study-essentials/study-essentials.component.scss new file mode 100644 index 0000000000..da586873e2 --- /dev/null +++ b/src/app/study-essentials/study-essentials.component.scss @@ -0,0 +1,314 @@ +@use '../unit-hub/hub-card' as hub; + +@include hub.card-theme; + +:host { + display: block; + min-width: 0; + color: var(--ot-color-text); +} + +.study-essentials { + @include hub.card(page); + padding: 24px; + border-radius: var(--ot-radius-lg); +} + +.essentials-heading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 24px; + margin-bottom: 16px; +} + +h2, +p { + margin: 0; +} + +h2 { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.15rem; + font-weight: 650; + line-height: 1.35; + + mat-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + font-size: 20px; + color: var(--ot-color-link); + } +} + +p, +.external-note { + margin-top: 4px; + color: var(--ot-color-text-muted); + font-size: 0.82rem; + line-height: 1.5; +} + +.essentials-links { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +li { + min-width: 0; +} + +a { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + height: 100%; + min-height: 112px; + box-sizing: border-box; + padding: 16px; + // nested inside the section card, so the link cards take the raised surface + @include hub.card(surface); + color: inherit; + text-decoration: none; + touch-action: manipulation; + overflow-wrap: anywhere; + transition: + border-color 150ms ease, + box-shadow 150ms ease; + + &:hover { + border-color: var(--ot-color-link); + box-shadow: var(--ot-elevation-1); + } + + &:hover strong { + text-decoration: underline; + text-underline-offset: 3px; + } + + &:focus-visible { + outline: 3px solid var(--ot-color-focus); + outline-offset: 3px; + } +} + +.essential-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--ot-radius-sm); + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); +} + +.essential-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding-right: 20px; + + strong { + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 650; + line-height: 1.35; + } + + > span { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + color: var(--ot-color-text-muted); + font-size: 0.8rem; + line-height: 1.45; + } + + > span.visually-hidden { + display: block; + } +} + +.external-icon { + position: absolute; + top: 16px; + right: 16px; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--ot-color-text-muted); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 599px) { + .study-essentials { + padding: 16px; + } + .essentials-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + a { + min-height: 96px; + padding: 12px; + } + .essential-copy > span:not(.visually-hidden) { + display: none; + } + .external-icon { + top: 12px; + right: 12px; + } +} + +// compact: a single row of pill links under the page header +.study-essentials.compact { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px 16px 16px; + + .essentials-heading { + flex: none; + margin: 0; + + p, + .external-note { + display: none; + } + + h2 { + font-size: 0.9rem; + white-space: nowrap; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } + } + } + + // fixed column counts that divide the six links evenly (3 x 2, then 2 x 3), + // so no row is left half empty and the cards line up with the content below + .essentials-links { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + min-width: 0; + } + + li { + display: flex; + } + + a { + flex: 1; + } + + // small two-line cards: name, then what it is for + a { + flex-direction: row; + align-items: flex-start; + gap: 10px; + width: auto; + min-height: 0; + height: 100%; + padding: 10px 28px 10px 10px; + border-radius: var(--ot-radius-md); + overflow-wrap: normal; + } + + .essential-icon { + flex: none; + width: 32px; + height: 32px; + border-radius: var(--ot-radius-sm); + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } + } + + .essential-copy { + gap: 2px; + padding-right: 0; + + strong { + font-size: 0.85rem; + } + + > span { + font-size: 0.75rem; + line-height: 1.35; + } + } + + .external-icon { + top: 10px; + right: 10px; + width: 14px; + height: 14px; + font-size: 14px; + } +} + +@media (max-width: 899px) { + .study-essentials.compact .essentials-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 599px) { + // phones: no sideways scroll, a two-column grid of the same cards + .study-essentials.compact { + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 12px; + + .essentials-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + a { + flex-direction: column; + gap: 6px; + width: auto; + padding: 10px 24px 10px 10px; + } + } +} + +@media (max-width: 339px) { + .study-essentials.compact .essentials-links { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/app/study-essentials/study-essentials.component.spec.ts b/src/app/study-essentials/study-essentials.component.spec.ts new file mode 100644 index 0000000000..75bbbe26c1 --- /dev/null +++ b/src/app/study-essentials/study-essentials.component.spec.ts @@ -0,0 +1,64 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {StudyEssentialsComponent} from './study-essentials.component'; +import {STUDY_ESSENTIALS_PROFILE, studyEssentialsFor} from './study-essentials.config'; + +describe('Study essentials', () => { + beforeEach(() => { + TestBed.configureTestingModule({imports: [StudyEssentialsComponent]}); + }); + + it('shows the six public Deakin services from the shared configured profile', () => { + const fixture = TestBed.createComponent(StudyEssentialsComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const titles = Array.from(element.querySelectorAll('a strong')).map((node) => node.textContent); + expect(titles).toEqual([ + 'DeakinSync', + 'CloudDeakin', + 'StudentConnect', + 'STAR timetable', + 'Deakin Library', + 'Student Central', + ]); + expect(element.querySelector('section')?.getAttribute('aria-label')).toBe('Study essentials'); + }); + + it('renders safe external links with no opener or referrer access', () => { + const fixture = TestBed.createComponent(StudyEssentialsComponent); + fixture.detectChanges(); + const links = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('a')); + expect(links).toHaveLength(6); + for (const link of links) { + expect(link.target).toBe('_blank'); + expect(link.rel).toBe('noopener noreferrer'); + expect(link.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(link.textContent).toContain('Opens in a new tab'); + } + }); + + it('uses only public Deakin entry points without course, account or meeting identifiers', () => { + for (const link of studyEssentialsFor('deakin')) { + const url = new URL(link.url); + expect(url.protocol).toBe('https:'); + expect(url.hostname.endsWith('.deakin.edu.au')).toBe(true); + expect(url.username).toBe(''); + expect(url.password).toBe(''); + expect(url.port).toBe(''); + expect(url.search).toBe(''); + expect(url.hash).toBe(''); + expect(url.pathname).not.toMatch(/\d|meeting|\/d2l\/home\//i); + } + const timetable = studyEssentialsFor('deakin').find((link) => link.title === 'STAR timetable'); + expect(timetable.url).toBe('https://www.deakin.edu.au/students/study-support/study-timetables'); + }); + + it('renders no Deakin links or panel when the institution profile is disabled', () => { + TestBed.overrideProvider(STUDY_ESSENTIALS_PROFILE, {useValue: ''}); + const fixture = TestBed.createComponent(StudyEssentialsComponent); + fixture.detectChanges(); + expect(studyEssentialsFor('')).toEqual([]); + expect((fixture.nativeElement as HTMLElement).querySelector('section')).toBeNull(); + expect((fixture.nativeElement as HTMLElement).querySelector('a')).toBeNull(); + }); +}); diff --git a/src/app/study-essentials/study-essentials.component.ts b/src/app/study-essentials/study-essentials.component.ts new file mode 100644 index 0000000000..3de6b32ad8 --- /dev/null +++ b/src/app/study-essentials/study-essentials.component.ts @@ -0,0 +1,17 @@ +import {ChangeDetectionStrategy, Component, inject, input} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import {STUDY_ESSENTIALS_PROFILE, studyEssentialsFor} from './study-essentials.config'; + +@Component({ + selector: 'f-study-essentials', + standalone: true, + imports: [MatIconModule], + templateUrl: './study-essentials.component.html', + styleUrl: './study-essentials.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StudyEssentialsComponent { + readonly links = studyEssentialsFor(inject(STUDY_ESSENTIALS_PROFILE)); + // one scrollable row of links, for pages where the tiles would push content down + readonly compact = input(false); +} diff --git a/src/app/study-essentials/study-essentials.config.ts b/src/app/study-essentials/study-essentials.config.ts new file mode 100644 index 0000000000..971f4500f6 --- /dev/null +++ b/src/app/study-essentials/study-essentials.config.ts @@ -0,0 +1,70 @@ +import {InjectionToken} from '@angular/core'; + +export type StudyEssentialsProfile = 'deakin' | ''; + +/** + * Shared deployment choice for development, production and the demo. + * This fork serves Deakin. Operators at another institution should set this + * to '' before building to hide these links, or add their own reviewed profile. + * Never infer institution membership from an email, product name or unit code. + */ +export const studyEssentialsProfile: StudyEssentialsProfile = 'deakin'; + +export const STUDY_ESSENTIALS_PROFILE: InjectionToken = new InjectionToken( + 'Study essentials profile', + {providedIn: 'root', factory: () => studyEssentialsProfile}, +); + +export interface StudyEssential { + readonly title: string; + readonly description: string; + readonly url: string; + readonly icon: string; +} + +// Public, non-session entry points verified against Deakin's official pages +// on 14 September 2026. Course sites and meeting links belong to their units. +const deakinEssentials: readonly StudyEssential[] = [ + { + title: 'DeakinSync', + description: 'Your unit sites and university services.', + url: 'https://sync.deakin.edu.au/', + icon: 'dashboard', + }, + { + title: 'CloudDeakin', + description: 'Learning materials and assessments.', + url: 'https://d2l.deakin.edu.au/', + icon: 'school', + }, + { + title: 'StudentConnect', + description: 'Enrolment, fees and results.', + url: 'https://studentconnect.deakin.edu.au/connect/webconnect', + icon: 'account_circle', + }, + { + title: 'STAR timetable', + description: 'Your timetable and calendar guidance.', + // The direct STAR application alternates between odd/even year addresses. + // Deakin maintains the current entry point on this stable public page. + url: 'https://www.deakin.edu.au/students/study-support/study-timetables', + icon: 'calendar_month', + }, + { + title: 'Deakin Library', + description: 'Books, articles and unit readings.', + url: 'https://www.deakin.edu.au/library', + icon: 'local_library', + }, + { + title: 'Student Central', + description: 'Course advice and student enquiries.', + url: 'https://www.deakin.edu.au/students/help/student-central', + icon: 'support_agent', + }, +]; + +export function studyEssentialsFor(profile: StudyEssentialsProfile): readonly StudyEssential[] { + return profile === 'deakin' ? deakinEssentials : []; +} diff --git a/src/app/unit-hub/_hub-card.scss b/src/app/unit-hub/_hub-card.scss new file mode 100644 index 0000000000..3f48fdb6a2 --- /dev/null +++ b/src/app/unit-hub/_hub-card.scss @@ -0,0 +1,294 @@ +// Shared grouping styles for the Unit Hub page, its dialogs and the Study essentials strip. +// Colours come only from --ot-* tokens. +// +// A card must stand apart from whatever it sits on. The divider token equals the raised +// surface in dark, so it is never used for a card edge here. + +// Emits the card edge token for a component. Include once at the top level of the stylesheet. +// Light keeps the soft edge it had before (the border token toned toward the surface). +// Dark needs the full border token, because the surfaces there sit close together. +@mixin card-theme { + :host { + --hub-card-border: color-mix(in srgb, var(--ot-color-border) 30%, var(--ot-color-surface)); + } + :host-context([data-ot-theme='dark']) { + --hub-card-border: var(--ot-color-border); + } +} + +// $on names the surface the card sits on: +// page - the page ground, e.g. feed cards and the manage panel +// raised - a raised surface, e.g. anything inside a dialog +// surface - inside another card that already uses the surface token +@mixin card($on: page) { + box-sizing: border-box; + border: 1px solid var(--hub-card-border); + border-radius: var(--ot-radius-md); + @if $on == surface { + background: var(--ot-color-surface-raised); + } @else { + background: var(--ot-color-surface); + } +} + +// Small uppercase label with a 16px icon, as used for facts and group headings. +@mixin label { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.4; + text-transform: uppercase; + + mat-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--ot-color-link); + } +} + +// A heading inside a card with a leading icon in the link colour. +@mixin icon-heading { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 1rem; + font-weight: 650; + line-height: 1.4; + + mat-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + font-size: 20px; + color: var(--ot-color-link); + } +} + +// One callout style for notices: leading icon, text beside it. +@mixin callout($on: page) { + @include card($on); + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + font-size: 0.86rem; + line-height: 1.55; + + > mat-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + margin-top: 1px; + font-size: 20px; + color: var(--ot-color-link); + } +} + +// Footer row of outlined or filled buttons. Wraps together, full width on phones. +@mixin action-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + + > a { + text-decoration: none; + } + + @media (max-width: 599px) { + flex-direction: column; + align-items: stretch; + + > a, + > button { + width: 100%; + min-height: 44px; + } + } +} + +// 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: 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); + text-decoration: line-through; + text-decoration-thickness: 1px; + } +} + +// One banner row stating the cancellation in words, with an icon. +// Error at 14% on the surface behind error pulled 40% toward the text colour: +// 6.8:1 in light and 5.4:1 in dark. +@mixin cancelled-banner { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + // 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 { + flex-shrink: 0; + width: 18px; + height: 18px; + font-size: 18px; + color: inherit; + } +} + +// 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); + } + } +} + +// 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) { + border-color: transparent; + background: #{$tone}; + color: #{$on}; + + > * { + --ot-color-text: #{$on}; + // Nothing on a solid fill can sit lighter than the on-colour, so a translucent muted + // tone loses contrast instead of gaining it: 82% measured 3.64:1 on the dark primary + // fill and 3.59:1 on the light complete one. Muted keeps the on-colour (4.51:1 at + // worst) and the smaller, heavier, uppercase type carries the step down instead. + --ot-color-text-muted: #{$on}; + --ot-color-link: #{$on}; + --ot-color-primary: #{$on}; + --ot-color-border: color-mix(in srgb, #{$on} 35%, transparent); + // The control edge was the one token this block missed, so an outlined button inside a + // solid card mixed a scope-local link with the GLOBAL slate border: 1.95:1 against the + // fill, where 1.4.11 asks 3:1. At 80% of the on-colour that outline measures 3.51:1 at + // worst and 4.89:1 at best, and the edge stays a step below the label. + --ot-color-control-border: color-mix(in srgb, #{$on} 80%, 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, so a green "Happening now" never + // sits green on green. The glass fill lifted the ground under its own white label to + // 3.18:1, and any fill lighter than the card does that, so the chip is an outline now: + // the card fill shows through and the label reads at the card's own 4.51:1 worst case. + // The edge is the same 3:1 control step as the buttons, 3.06:1 at worst. + :where(.chip, .unit-badge, .kind-chip) { + border-color: color-mix(in srgb, #{$on} 70%, transparent); + background: transparent; + color: #{$on}; + } +} 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/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/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/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/teams-meeting-composer.component.html b/src/app/unit-hub/teams-meeting-composer.component.html new file mode 100644 index 0000000000..4f3503fdd1 --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.html @@ -0,0 +1,81 @@ +
+

+ Make a Teams meeting +

+

+ Open a draft with these session details. Check the invitation, then press Send in Teams. +

+ @if (draft) { +
+

Meeting

+

+ {{ unitCode }} · {{ draft.title }}
{{ schedule }} +

+
+
+

Invitation

+ + +
+ @if (draft.recurrence === 'weekly') { +
+ +

+ This opens one session. Set the weekly repeat schedule in Teams before sending. + @if (draft.recurrence_until) { + Use {{ draft.recurrence_until }} as the last date. + } +

+
+ } + } + @let result = launch; +
+ @if (result.url) { + Open Teams draft + } @else { + +

{{ result.error }}

+ } +
+
+ +
+

+ Your OnTrack form stays open. After sending in Teams, copy the meeting’s join link back into + the session and save it. +

+

+ Teams may ask you to sign in or choose its app or website. If the draft does not open on + your phone, use a desktop browser or copy the details into Teams. +

+
+
+
diff --git a/src/app/unit-hub/teams-meeting-composer.component.scss b/src/app/unit-hub/teams-meeting-composer.component.scss new file mode 100644 index 0000000000..6f33bb392f --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.scss @@ -0,0 +1,151 @@ +@use './hub-card' as hub; + +@include hub.card-theme; + +:host { + display: block; + min-width: 0; + // 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; +} + +// One card. Inside it, labelled groups separated by spacing, never by rules. +// The host page can set --hub-composer-surface when the composer sits inside another card. +.teams-composer { + @include hub.card(raised); + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 8px; + padding: 16px; + background: var(--hub-composer-surface, var(--ot-color-surface)); + color: var(--ot-color-text); + overflow-wrap: anywhere; +} + +.composer-heading { + @include hub.icon-heading; +} + +p { + margin: 0; +} +.composer-intro { + margin-top: -8px; + font-size: 0.9rem; + line-height: 1.5; +} + +.composer-group { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} +.group-label { + @include hub.label; +} +.meeting-subject p:not(.group-label) { + line-height: 1.6; +} + +.field { + display: grid; + gap: 0.35rem; + margin: 0 0 8px; + font-size: 0.85rem; + font-weight: 600; +} + +input, +textarea { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 0.65rem; + // Muted text, not the border token: that is 1.6:1 in dark, too faint for a field edge. + border: 1px solid var(--ot-color-text-muted); + border-radius: var(--ot-radius-sm); + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); + font: inherit; + font-weight: 400; + + &::placeholder { + color: var(--ot-color-text-muted); + opacity: 1; + } +} + +textarea { + resize: vertical; +} +small, +.handoff-note, +.fallback-note { + color: var(--ot-color-text-muted); + font-size: 0.85rem; + font-weight: 400; +} +.fallback-note { + margin-top: 6px; +} + +// icon beside the copy, no border: it already sits inside the composer card +.composer-note { + display: flex; + align-items: flex-start; + gap: 10px; + line-height: 1.5; + + > mat-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + margin-top: 1px; + font-size: 18px; + color: var(--ot-color-link); + } +} +.repeat-note p { + font-weight: 600; +} + +.composer-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + + a { + text-decoration: none; + } +} +.draft-error { + // Pulled a fifth of the way to the text colour, which keeps it above 4.5:1 on the + // card in both themes (the plain error token is 4.2:1 on a tinted box in light). + color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text)); + font-size: 0.85rem; +} +input:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 3px solid var(--ot-color-focus); + outline-offset: 2px; +} + +@media (max-width: 599px) { + .teams-composer { + padding: 12px; + } + .composer-actions { + flex-direction: column; + align-items: stretch; + } + .composer-actions a, + .composer-actions button { + width: 100%; + min-height: 44px; + } +} diff --git a/src/app/unit-hub/teams-meeting-composer.component.spec.ts b/src/app/unit-hub/teams-meeting-composer.component.spec.ts new file mode 100644 index 0000000000..8e62e89ec6 --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.spec.ts @@ -0,0 +1,146 @@ +import {describe, expect, it} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; +import {TeamsMeetingDraft} from './teams-meeting-draft'; + +const session = (): TeamsMeetingDraft => ({ + title: 'Computer Systems HelpHub', + description: 'Bring your questions.', + start_at: '2026-09-17T07:00:00Z', + end_at: '2026-09-17T08:00:00Z', + timezone: 'Australia/Melbourne', + recurrence: 'none', +}); + +function open(draft: TeamsMeetingDraft | null = session()) { + const fixture = TestBed.createComponent(TeamsMeetingComposerComponent); + fixture.componentRef.setInput('draft', draft); + fixture.componentRef.setInput('unitCode', 'SIT111'); + fixture.detectChanges(); + return { + fixture, + component: fixture.componentInstance, + element: fixture.nativeElement as HTMLElement, + }; +} + +describe('Teams meeting composer', () => { + it('does not submit an enclosing OnTrack form when Enter is pressed in attendees', () => { + const {element} = open(); + const event = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + element.querySelector('input').dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('renders a labelled, explicit draft link without any meeting creation action', () => { + const {element} = open(); + const link = element.querySelector('a') as HTMLAnchorElement; + expect(link.textContent).toContain('Open Teams draft'); + expect(link.origin).toBe('https://teams.microsoft.com'); + expect(link.pathname).toBe('/l/meeting/new'); + expect(link.target).toBe('_blank'); + expect(link.rel).toBe('noopener noreferrer'); + expect(element.textContent).toContain('press Send in Teams'); + expect(element.textContent).toContain('copy the meeting’s join link back'); + expect(element.querySelector('input')?.closest('label')?.textContent).toContain('Attendees'); + expect(element.querySelector('textarea')?.closest('label')?.textContent).toContain( + 'Teams details', + ); + expect(element.querySelector('form')).toBeNull(); + }); + + it('shows an actionable reason when the parent form is incomplete', () => { + const {fixture, element} = open(null); + fixture.componentRef.setInput('unavailableReason', 'Choose a valid end time.'); + fixture.detectChanges(); + expect(element.querySelector('a')).toBeNull(); + expect(element.querySelector('button')?.disabled).toBe(true); + expect(element.querySelector('button')?.type).toBe('button'); + expect(element.querySelector('[role="status"]')?.textContent).toContain( + 'Choose a valid end time', + ); + }); + + it('uses locally edited details and attendees while leaving the parent draft untouched', () => { + const draft = Object.freeze(session()); + const {fixture, component, element} = open(draft); + component.content.setValue('Shorter invitation'); + component.attendees.setValue('tutor@example.edu'); + fixture.detectChanges(); + const url = new URL((element.querySelector('a') as HTMLAnchorElement).href); + expect(url.searchParams.get('content')).toBe('Shorter invitation'); + expect(url.searchParams.get('attendees')).toBe('tutor@example.edu'); + expect(draft.description).toBe('Bring your questions.'); + expect(draft.title).toBe('Computer Systems HelpHub'); + }); + + it('retains composer edits across equivalent snapshots and time adjustments', () => { + const {fixture, component} = open(); + component.content.setValue('My draft details'); + component.attendees.setValue('tutor@example.edu'); + fixture.componentRef.setInput('draft', {...session()}); + fixture.detectChanges(); + expect(component.content.value).toBe('My draft details'); + fixture.componentRef.setInput('draft', {...session(), end_at: '2026-09-17T09:00:00Z'}); + fixture.detectChanges(); + expect(component.content.value).toBe('My draft details'); + expect(new URL(component.launch.url).searchParams.get('endTime')).toBe( + '2026-09-17T09:00:00.000Z', + ); + }); + + it('clears attendee selection when the unit changes or the draft is removed', () => { + const {fixture, component} = open(); + component.attendees.setValue('previous-unit@example.edu'); + fixture.componentRef.setInput('unitCode', 'SIT102'); + fixture.detectChanges(); + expect(component.attendees.value).toBe(''); + expect(component.content.value).toContain('SIT102'); + component.attendees.setValue('tutor@example.edu'); + fixture.componentRef.setInput('draft', null); + fixture.detectChanges(); + expect(component.attendees.value).toBe(''); + expect(component.content.value).toBe(''); + expect(component.launch.url).toBeNull(); + }); + + it('updates details from changed session content and never renders it as HTML', () => { + const {fixture, component, element} = open(); + fixture.componentRef.setInput('draft', { + ...session(), + description: '', + }); + fixture.detectChanges(); + expect(component.content.value).toContain(' { + const {element} = open({...session(), recurrence: 'weekly', recurrence_until: '2026-11-26'}); + expect(element.textContent).toContain('Set the weekly repeat schedule in Teams'); + expect(element.textContent).toContain('2026-11-26'); + expect(element.textContent).toContain('If the draft does not open on your phone'); + expect( + new URL((element.querySelector('a') as HTMLAnchorElement).href).searchParams.has( + 'recurrence', + ), + ).toBe(false); + }); + + it('removes the actionable link for invalid attendees and oversized drafts, then recovers', () => { + const {fixture, component, element} = open(); + component.attendees.setValue('not an email'); + fixture.detectChanges(); + expect(element.querySelector('a')).toBeNull(); + expect(element.textContent).toContain('sign-in email addresses'); + component.attendees.setValue(''); + component.content.setValue('🙂'.repeat(1500)); + fixture.detectChanges(); + expect(element.querySelector('a')).toBeNull(); + expect(element.textContent).toContain('Shorten the Teams details'); + component.content.setValue('Shortened safely'); + fixture.detectChanges(); + expect(element.querySelector('a')).not.toBeNull(); + }); +}); diff --git a/src/app/unit-hub/teams-meeting-composer.component.ts b/src/app/unit-hub/teams-meeting-composer.component.ts new file mode 100644 index 0000000000..16ef1f33c7 --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.ts @@ -0,0 +1,87 @@ +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, Input, OnChanges} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {TeamsMeetingDraft, teamsMeetingContent, teamsMeetingDraftUrl} from './teams-meeting-draft'; + +@Component({ + selector: 'f-teams-meeting-composer', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, MatButtonModule, MatIconModule], + templateUrl: './teams-meeting-composer.component.html', + styleUrl: './teams-meeting-composer.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class TeamsMeetingComposerComponent implements OnChanges { + @Input() draft: TeamsMeetingDraft | null = null; + @Input() unitCode = ''; + @Input() unavailableReason = ''; + + readonly attendees = new FormControl('', {nonNullable: true}); + readonly content = new FormControl('', {nonNullable: true}); + private previousUnit = ''; + private initialContent: string | null = null; + + ngOnChanges(): void { + if (this.previousUnit !== this.unitCode || !this.draft) { + this.attendees.setValue(''); + this.previousUnit = this.unitCode; + } + if (!this.draft) { + this.initialContent = null; + this.content.setValue(''); + return; + } + try { + const initial = teamsMeetingContent(this.draft, this.unitCode); + // A parent getter can return a fresh snapshot on every change detection pass. + if (initial !== this.initialContent) { + this.initialContent = initial; + this.content.setValue(initial); + } + } catch { + this.initialContent = null; + this.content.setValue(''); + } + } + + get launch(): {url: string | null; error: string} { + if (!this.draft) { + return { + url: null, + error: this.unavailableReason || 'Enter the session title, dates and time zone first.', + }; + } + try { + return { + url: teamsMeetingDraftUrl(this.draft, this.unitCode, { + attendees: this.attendees.value, + content: this.content.value, + }), + error: '', + }; + } catch (error) { + return { + url: null, + error: error instanceof Error ? error.message : 'Check the meeting details.', + }; + } + } + + get schedule(): string { + if (!this.draft) { + return ''; + } + try { + const format = new Intl.DateTimeFormat(undefined, { + timeZone: this.draft.timezone, + dateStyle: 'medium', + timeStyle: 'short', + }); + return `${format.format(new Date(this.draft.start_at))} – ${format.format(new Date(this.draft.end_at))} (${this.draft.timezone})`; + } catch { + return ''; + } + } +} diff --git a/src/app/unit-hub/teams-meeting-draft.spec.ts b/src/app/unit-hub/teams-meeting-draft.spec.ts new file mode 100644 index 0000000000..ccec892c85 --- /dev/null +++ b/src/app/unit-hub/teams-meeting-draft.spec.ts @@ -0,0 +1,166 @@ +import {describe, expect, it} from 'vitest'; +import { + TEAMS_MEETING_MAX_URL_LENGTH, + TeamsMeetingDraft, + teamsMeetingAttendees, + teamsMeetingContent, + teamsMeetingDraftUrl, +} from './teams-meeting-draft'; +import {dateTimeToIso} from './unit-hub-calendar'; + +const session = (): TeamsMeetingDraft => ({ + title: 'Hardware questions & answers', + description: 'Bring your circuit questions.\nWe will discuss the next task.', + start_at: '2026-09-17T17:00:00+10:00', + end_at: '2026-09-17T18:00:00+10:00', + timezone: 'Australia/Melbourne', + location: 'Online', + recurrence: 'none', +}); + +describe('Teams meeting draft URLs', () => { + it('uses the supported HTTPS scheduling endpoint and only documented parameters', () => { + const value = teamsMeetingDraftUrl(session(), 'SIT111'); + const url = new URL(value); + expect(url.origin + url.pathname).toBe('https://teams.microsoft.com/l/meeting/new'); + expect([...url.searchParams.keys()]).toEqual(['subject', 'startTime', 'endTime', 'content']); + expect(url.searchParams.get('subject')).toBe('SIT111 · Hardware questions & answers'); + expect(url.searchParams.get('startTime')).toBe('2026-09-17T07:00:00.000Z'); + expect(url.searchParams.get('endTime')).toBe('2026-09-17T08:00:00.000Z'); + expect(url.searchParams.get('content')).toContain('Location: Online'); + expect(value).toContain('%20'); + expect(value).not.toContain('+'); + }); + + it('converts Melbourne summer time without depending on the device time zone', () => { + const draft = { + ...session(), + start_at: dateTimeToIso('2026-10-08T17:00', 'Australia/Melbourne'), + end_at: dateTimeToIso('2026-10-08T18:00', 'Australia/Melbourne'), + }; + const url = new URL(teamsMeetingDraftUrl(draft, 'SIT111')); + expect(url.searchParams.get('startTime')).toBe('2026-10-08T06:00:00.000Z'); + expect(url.searchParams.get('endTime')).toBe('2026-10-08T07:00:00.000Z'); + }); + + it('handles half-hour offsets and an end time on the next local day', () => { + const draft = { + ...session(), + timezone: 'Asia/Kolkata', + start_at: '2026-09-17T23:30:00+05:30', + end_at: '2026-09-18T00:30:00+05:30', + }; + const url = new URL(teamsMeetingDraftUrl(draft, 'SIT111')); + expect(url.searchParams.get('startTime')).toBe('2026-09-17T18:00:00.000Z'); + expect(url.searchParams.get('endTime')).toBe('2026-09-17T19:00:00.000Z'); + }); + + it.each([ + '2026-09-17T17:00', + '2026-02-30T17:00:00Z', + '2026-09-17T24:00:00Z', + '2026-09-17T17:00:60Z', + 'not a date', + ])('rejects an invalid or unzoned time: %s', (value) => { + expect(() => teamsMeetingDraftUrl({...session(), start_at: value}, 'SIT111')).toThrow(); + }); + + it('rejects cancelled sessions, backwards times and invalid time zones', () => { + expect(() => teamsMeetingDraftUrl({...session(), cancelled: true}, 'SIT111')).toThrow( + /scheduled/, + ); + expect(() => + teamsMeetingDraftUrl({...session(), end_at: session().start_at}, 'SIT111'), + ).toThrow(/after/); + expect(() => teamsMeetingDraftUrl({...session(), timezone: 'Mars/Anywhere'}, 'SIT111')).toThrow( + /time zone/, + ); + expect(() => teamsMeetingDraftUrl({...session(), timezone: ''}, 'SIT111')).toThrow(/time zone/); + }); + + it('encodes query delimiters, literal plus signs and Unicode as data', () => { + const draft = { + ...session(), + title: 'C++ & 电路 #1', + description: 'Review &attendees=other@example.edu', + }; + const url = new URL( + teamsMeetingDraftUrl(draft, 'SIT111', {attendees: 'person+lab@example.edu'}), + ); + expect([...url.searchParams.keys()]).toHaveLength(5); + expect(url.searchParams.get('subject')).toBe('SIT111 · C++ & 电路 #1'); + expect(url.searchParams.get('content')).toContain('Review &attendees=other@example.edu'); + expect(url.searchParams.get('attendees')).toBe('person+lab@example.edu'); + expect(url.hash).toBe(''); + }); + + it('accepts explicit Entra UPNs, removes duplicates and omits empty attendees', () => { + expect(teamsMeetingAttendees('A@example.edu; b@example.edu\nA@EXAMPLE.edu')).toEqual([ + 'A@example.edu', + 'b@example.edu', + ]); + expect( + new URL(teamsMeetingDraftUrl(session(), 'SIT111', {attendees: ' '})).searchParams.has( + 'attendees', + ), + ).toBe(false); + expect(teamsMeetingAttendees('guest_example.com#EXT#@tenant.onmicrosoft.com')).toHaveLength(1); + }); + + it.each([ + 'Display Name ', + 'a@example.edu&content=bad', + 'a@-bad.edu', + 'a@example..edu', + 'a@example.edu,', + 'a@example.edu\r\nBcc: b@example.edu', + ])('rejects malformed attendee input: %s', (value) => { + expect(() => teamsMeetingAttendees(value)).toThrow(/sign-in/); + }); + + it('bounds attendees and URL length without silently losing details', () => { + const many = Array.from({length: 21}, (_, i) => `person${i}@example.edu`).join(','); + expect(() => teamsMeetingAttendees(many)).toThrow(/20 attendees/); + const draft = {...session(), description: '🙂'.repeat(1500)}; + expect(() => teamsMeetingDraftUrl(draft, 'SIT111')).toThrow(/too long/); + const shortened = teamsMeetingDraftUrl(draft, 'SIT111', {content: 'Short draft details'}); + expect(shortened.length).toBeLessThanOrEqual(TEAMS_MEETING_MAX_URL_LENGTH); + expect(draft.description).toHaveLength(3000); + }); + + it('never copies an existing join URL, source URL or invents recurrence parameters', () => { + const draft = { + ...session(), + recurrence: 'weekly' as const, + recurrence_until: '2026-11-26', + join_url: 'https://teams.microsoft.com/l/meetup-join/existing', + source_url: 'https://example.edu/private', + }; + const url = new URL(teamsMeetingDraftUrl(draft, 'SIT111')); + expect(url.searchParams.has('recurrence')).toBe(false); + expect(url.searchParams.has('location')).toBe(false); + expect(url.searchParams.get('content')).not.toContain('https://'); + }); + + it('rejects control characters and overlong fields while preserving ordinary line breaks', () => { + expect(teamsMeetingContent({...session(), description: 'First\r\nSecond'}, 'SIT111')).toContain( + 'First\nSecond', + ); + expect(() => teamsMeetingDraftUrl({...session(), title: 'bad\nsubject'}, 'SIT111')).toThrow( + /one line/, + ); + expect(() => + teamsMeetingDraftUrl({...session(), description: 'bad\u0000text'}, 'SIT111'), + ).toThrow(/characters/); + expect(() => teamsMeetingDraftUrl({...session(), title: 'x'.repeat(201)}, 'SIT111')).toThrow( + /200/, + ); + }); + + it('opens a draft for an API session saved without a description', () => { + const draft = {...session(), description: null as unknown as string, location: null}; + expect(teamsMeetingContent(draft, 'SIT111')).toBe('Unit: SIT111'); + const url = new URL(teamsMeetingDraftUrl(draft, 'SIT111')); + expect(url.searchParams.get('content')).toBe('Unit: SIT111'); + }); +}); diff --git a/src/app/unit-hub/teams-meeting-draft.ts b/src/app/unit-hub/teams-meeting-draft.ts new file mode 100644 index 0000000000..40027f2bd7 --- /dev/null +++ b/src/app/unit-hub/teams-meeting-draft.ts @@ -0,0 +1,151 @@ +/** A snapshot of the editor. Times are resolved instants, not local input values. */ +export interface TeamsMeetingDraft { + title: string; + description: string; + start_at: string; + end_at: string; + timezone: string; + location?: string | null; + recurrence?: 'none' | 'weekly'; + recurrence_until?: string | null; + cancelled?: boolean; +} + +export interface TeamsMeetingOptions { + attendees?: string; + content?: string; +} + +// These are OnTrack's bounds for a portable draft URL, not Microsoft service limits. +export const TEAMS_MEETING_MAX_URL_LENGTH = 8000; +export const TEAMS_MEETING_MAX_ATTENDEES = 20; + +function plainText(value: string, label: string, maximum: number): string { + if (typeof value !== 'string' || value.length > maximum) { + throw new Error(`${label} must be ${maximum} characters or fewer.`); + } + if (/\p{Cc}/u.test(value.replace(/[\r\n\t]/g, ''))) { + throw new Error(`${label} contains unsupported characters.`); + } + return value.replace(/\r\n?/g, '\n').trim(); +} + +function instant(value: string): string { + const match = + typeof value === 'string' && + value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/); + if (!match) { + throw new Error('Set a complete start and end time, including the time zone.'); + } + const wall = `${match[1]}:${match[2] || '00'}`; + const wallDate = new Date(`${wall}Z`); + const date = new Date(value); + if ( + !Number.isFinite(date.getTime()) || + !Number.isFinite(wallDate.getTime()) || + wallDate.toISOString().slice(0, 19) !== wall + ) { + throw new Error('Set a valid start and end time.'); + } + return date.toISOString(); +} + +export function teamsMeetingAttendees(value = ''): string[] { + if (value.length > 5100) { + throw new Error(`Add no more than ${TEAMS_MEETING_MAX_ATTENDEES} attendees.`); + } + const entries = value.trim() ? value.split(/[,;\n]+/).map((entry) => entry.trim()) : []; + const attendees: string[] = []; + const seen: Set = new Set(); + for (const entry of entries) { + const pieces = entry.split('@'); + const validDomain = pieces[1] + ?.split('.') + .every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label)); + if ( + entry.length > 254 || + pieces.length !== 2 || + !validDomain || + !/^[a-z\d.!#$%&'*+/=?^_`{|}~-]+$/i.test(pieces[0]) + ) { + throw new Error('Enter university sign-in email addresses, separated by commas.'); + } + const key = entry.toLowerCase(); + if (!seen.has(key)) { + attendees.push(entry); + seen.add(key); + } + } + if (attendees.length > TEAMS_MEETING_MAX_ATTENDEES) { + throw new Error( + `Add no more than ${TEAMS_MEETING_MAX_ATTENDEES} attendees here; add others in Teams.`, + ); + } + return attendees; +} + +export function teamsMeetingContent(draft: TeamsMeetingDraft, unitCode: string): string { + const code = plainText(unitCode, 'Unit code', 50); + // The API returns null when a session was saved without a description. + const description = plainText(draft.description || '', 'Session details', 20000); + const location = plainText(draft.location || '', 'Location', 300); + return [code ? `Unit: ${code}` : '', description, location ? `Location: ${location}` : ''] + .filter(Boolean) + .join('\n\n'); +} + +/** + * Open a scheduling draft only. This never creates a meeting or discovers a join URL. + * https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/deep-link-workflow + * Only documented fields are used. Recurrence and location are not URL parameters. + */ +export function teamsMeetingDraftUrl( + draft: TeamsMeetingDraft, + unitCode: string, + options: TeamsMeetingOptions = {}, +): string { + if (!draft || draft.cancelled) { + throw new Error('Use a scheduled session to prepare a Teams meeting.'); + } + const title = plainText(draft.title, 'Session title', 200); + const code = plainText(unitCode, 'Unit code', 50); + if (!title || /[\r\n]/.test(title) || /[\r\n]/.test(code)) { + throw new Error('Enter a session title on one line.'); + } + const start = instant(draft.start_at); + const end = instant(draft.end_at); + if (new Date(end).getTime() <= new Date(start).getTime()) { + throw new Error('The end time must be after the start time.'); + } + try { + new Intl.DateTimeFormat('en', {timeZone: draft.timezone}).format(new Date(start)); + if (!draft.timezone) { + throw new Error(); + } + } catch { + throw new Error('Choose a valid session time zone.'); + } + const defaultContent = teamsMeetingContent(draft, code); + const content = plainText(options.content ?? defaultContent, 'Teams details', 20500); + const attendees = teamsMeetingAttendees(options.attendees); + const parameters: Record = { + subject: code ? `${code} · ${title}` : title, + startTime: start, + endTime: end, + content, + }; + if (attendees.length) { + parameters.attendees = attendees.join(','); + } + // URLSearchParams uses + for spaces, which this Teams endpoint does not support. + const query = Object.entries(parameters) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); + const url = `https://teams.microsoft.com/l/meeting/new?${query}`; + if (url.length > TEAMS_MEETING_MAX_URL_LENGTH) { + throw new Error( + 'This draft is too long to open reliably. Shorten the Teams details or add attendees in Teams.', + ); + } + return url; +} diff --git a/src/app/unit-hub/unit-hub-demo.fixtures.ts b/src/app/unit-hub/unit-hub-demo.fixtures.ts new file mode 100644 index 0000000000..12fdd090b8 --- /dev/null +++ b/src/app/unit-hub/unit-hub-demo.fixtures.ts @@ -0,0 +1,92 @@ +import {DemoMeetingLinks, safeDemoMeetingUrl} from '../demo/demo-meeting-links.store'; +import {UnitHubFeed} from './unit-hub.models'; + +/** Synthetic content only. SIT102 deliberately exists in the raw fixture as an isolation test. */ +export function unitHubDemo(now = new Date(), links?: DemoMeetingLinks): UnitHubFeed { + const helpHubLink = safeDemoMeetingUrl(links?.helpHub || ''); + const extraLink = safeDemoMeetingUrl(links?.extraHelpHub || ''); + const start = new Date(now); + start.setDate(start.getDate() + 1); + start.setHours(17, 0, 0, 0); + const lecture = new Date(start.getTime() + 86400000); + const shared = { + description: 'Bring your questions and join the teaching team for a guided session.', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + published: true, + cancelled: false, + recurrence: 'none' as const, + join_url: null, + location: 'Online in Teams', + }; + return { + units: [{id: 111, code: 'SIT111', name: 'Computer Systems', can_manage: false}], + announcements: [ + { + id: 1, + unit_id: 111, + title: 'Your week in Computer Systems', + body: 'Review the hardware concepts, bring questions to HelpHub and check your upcoming classes here. This is a fictional example for the OnTrack demonstration.', + published_at: now.toISOString(), + pinned: true, + }, + { + id: 2, + unit_id: 111, + title: 'Extra help before your next task', + body: 'The teaching team has added an online HelpHub. Open the session card for its time and meeting options.', + published_at: new Date(now.getTime() - 86400000).toISOString(), + pinned: false, + }, + { + id: 3, + unit_id: 102, + title: 'SIT102 announcement must stay hidden', + body: 'Other unit sample', + published_at: now.toISOString(), + pinned: false, + }, + ], + sessions: [ + { + ...shared, + id: 1, + unit_id: 111, + title: 'Computer Systems HelpHub', + kind: 'helphub', + join_url: helpHubLink, + demo_hosted_join: !!helpHubLink, + start_at: start.toISOString(), + end_at: new Date(start.getTime() + 3600000).toISOString(), + }, + { + ...shared, + id: 2, + unit_id: 111, + title: 'Hardware review lecture', + kind: 'lecture', + start_at: lecture.toISOString(), + end_at: new Date(lecture.getTime() + 3600000).toISOString(), + }, + { + ...shared, + id: 4, + unit_id: 111, + title: 'Computer Systems extra HelpHub', + kind: 'helphub', + join_url: extraLink, + demo_hosted_join: !!extraLink, + start_at: new Date(lecture.getTime() + 86400000).toISOString(), + end_at: new Date(lecture.getTime() + 90000000).toISOString(), + }, + { + ...shared, + id: 3, + unit_id: 102, + title: 'SIT102 HelpHub must stay hidden', + kind: 'helphub', + start_at: start.toISOString(), + end_at: new Date(start.getTime() + 3600000).toISOString(), + }, + ], + }; +} diff --git a/src/app/unit-hub/unit-hub-details.component.html b/src/app/unit-hub/unit-hub-details.component.html new file mode 100644 index 0000000000..3a915f492e --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.html @@ -0,0 +1,261 @@ +@if (content; as detail) { +
+
+

{{ detail.unitCode }} · {{ detail.unitName }}

+

+ @if (cancelled) { + Cancelled: + } + {{ title }} +

+ @if (cancelled) { +

+ Cancelled: this session will not run +

+ } +
+ +
+ + + @if (detail.demo) { + + } + @if (detail.announcement; as announcement) { +
+
+
Unit
+
{{ detail.unitCode }} · {{ detail.unitName }}
+
+
+
Source
+
{{ sourceLabel }}
+
+ @if (announcement.published_at) { +
+
Published
+
+ {{ deviceTimeZone }} +
+
+ } + @if (announcement.author_name) { +
+
Posted by
+
{{ announcement.author_name }}
+
+ } + @if (announcement.expires_at) { +
+
Visible until
+
+ {{ announcement.expires_at | date: 'd MMMM yyyy, h:mm a' + }}{{ deviceTimeZone }} +
+
+ } + @if (announcement.pinned) { +
+
Priority
+
Pinned announcement
+
+ } +
+
+ +
+ @if (bodyCollapsible) { + + } +
+ @if (safeUrl(announcement.source_url)) { + Open original post + } + } + + @if (detail.session; as session) { +
+
+
Session
+
{{ sessionKind }}
+
+
+
Unit
+
{{ detail.unitCode }} · {{ detail.unitName }}
+
+
+
Starts
+
+ {{ deviceTimeZone }} +
+
+
+
Ends
+
+ {{ deviceTimeZone }} +
+
+ @if (session.timezone !== deviceTimeZone) { +
+
Schedule time zone
+
+ {{ session.timezone }}Dates above are converted to your time zone. +
+
+ } + @if (session.location) { +
+
Location
+
{{ session.location }}
+
+ } +
+
Repeat
+
+ {{ session.recurrence === 'weekly' ? 'Part of a weekly series' : 'One session' }} + @if (session.recurrence === 'weekly' && session.recurrence_until) { + Series ends {{ session.recurrence_until | date: 'd MMMM yyyy' }} + } +
+
+
+
+ +
+ @if (bodyCollapsible) { + + } +
+ @if (safeUrl(session.source_url)) { + View original session details + } + @if (!session.cancelled) { + + } + @if (detail.canManage && !detail.demo && !session.cancelled) { + + } + } +

{{ status }}

+ + + +
+ + + @if (detail.session && !detail.session.cancelled) { + @if (safeUrl(detail.joinUrl)) { + {{ detail.demo ? 'Join hosted demo' : 'Join session' }} + } + @if (safeUrl(detail.googleCalendarUrl)) { + {{ detail.demo ? 'Preview DEMO in Google Calendar' : 'Add to Google Calendar' }} + } + @if (detail.download) { + + } + } + + +} diff --git a/src/app/unit-hub/unit-hub-details.component.scss b/src/app/unit-hub/unit-hub-details.component.scss new file mode 100644 index 0000000000..981f0ec573 --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.scss @@ -0,0 +1,198 @@ +@use './hub-card' as hub; + +@include hub.card-theme; + +:host { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 90dvh; + color: var(--ot-color-text); + // 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; +} +.details-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 22px 20px 12px 24px; + flex-shrink: 0; +} +.details-header > div { + flex: 1; + min-width: 0; +} +.details-header > button { + flex-shrink: 0; +} +.detail-unit { + margin: 0 0 8px; + color: var(--ot-color-link); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + overflow-wrap: anywhere; +} +h2[mat-dialog-title] { + margin: 0; + padding: 0; + color: inherit; + font-size: 1.35rem; + font-weight: 650; + line-height: 1.3; + overflow-wrap: anywhere; +} +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; + padding: 6px 24px 24px; + 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: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px 24px; + margin-top: 10px; + padding: 16px; +} +.detail-facts > div { + min-width: 0; +} +dt { + @include hub.label; + margin-bottom: 4px; +} +dd { + margin: 0; + font-size: 0.9rem; + line-height: 1.5; +} +small { + display: block; + color: var(--ot-color-text-muted); + 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; +} +// 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 { + @include hub.callout(raised); + p { + margin: 0; + } +} +// a cancelled session: struck title in the header, the banner under it +.details-header.is-cancelled { + h2[mat-dialog-title] { + text-decoration: line-through; + text-decoration-thickness: 1px; + } +} +.cancelled-banner { + @include hub.cancelled-banner; + margin-top: 10px; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.calendar-guidance { + @include hub.card(raised); + padding: 16px; + font-size: 0.84rem; + line-height: 1.6; +} +.calendar-guidance h3 { + @include hub.icon-heading; + margin-bottom: 8px; +} +.calendar-guidance p { + margin: 0 0 12px; +} +.detail-source { + align-self: flex-start; + text-decoration: none; +} +.details-actions { + @include hub.action-row; + flex-shrink: 0; + justify-content: flex-end; + padding: 14px 24px; +} +.detail-status:empty { + display: none; +} +.detail-status { + font-size: 0.84rem; +} +a:focus-visible, +button:focus-visible { + outline: 3px solid var(--ot-color-focus); + outline-offset: 3px; +} +@media (max-width: 599px) { + .details-header { + padding: 18px 14px 10px 18px; + } + .details-content { + padding: 6px 18px 18px; + } + .details-actions { + padding: 12px 18px; + } + .detail-facts { + grid-template-columns: minmax(0, 1fr); + gap: 14px; + } + h2[mat-dialog-title] { + font-size: 1.22rem; + } + .detail-source { + align-self: stretch; + } +} diff --git a/src/app/unit-hub/unit-hub-details.component.spec.ts b/src/app/unit-hub/unit-hub-details.component.spec.ts new file mode 100644 index 0000000000..a654836a21 --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.spec.ts @@ -0,0 +1,149 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {unitHubDemo} from './unit-hub-demo.fixtures'; +import {UnitHubDetailsComponent, UnitHubDetailsData} from './unit-hub-details.component'; + +const session = () => unitHubDemo(new Date('2026-09-14T06:00:00Z')).sessions[0]; + +describe('Unit Hub full details', () => { + let close: ReturnType; + beforeEach(() => { + close = vi.fn(); + }); + + async function render(data: Partial) { + TestBed.configureTestingModule({ + imports: [UnitHubDetailsComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + unitCode: 'SIT111', + unitName: 'Computer Systems', + demo: false, + canManage: false, + ...data, + }, + }, + {provide: MatDialogRef, useValue: {close}}, + ], + }); + const fixture = TestBed.createComponent(UnitHubDetailsComponent); + fixture.detectChanges(); + await fixture.whenStable(); + return { + fixture, + component: fixture.componentInstance, + element: fixture.nativeElement as HTMLElement, + }; + } + + it('shows full dates, zone, recurrence, source and content without injecting HTML', async () => { + const row = { + ...session(), + recurrence: 'weekly' as const, + recurrence_until: '2026-10-14', + description: 'First line\n\nLast line', + source_url: 'https://teams.microsoft.com/l/message/example', + }; + const {element} = await render({session: row}); + const text = element.textContent.replace(/\s+/g, ' '); + expect(text).toContain('First line Last line'); + expect(element.querySelector('script')).toBeNull(); + expect(element.querySelector(`time[datetime="${row.start_at}"]`)).not.toBeNull(); + expect(element.querySelector(`time[datetime="${row.end_at}"]`)).not.toBeNull(); + expect(text).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone); + expect(text).toContain('Part of a weekly series'); + expect( + element + .querySelector('a[href="https://teams.microsoft.com/l/message/example"]') + ?.getAttribute('rel'), + ).toBe('noopener noreferrer'); + }); + + it('suppresses unsafe links even when supplied to the dialog directly', async () => { + const {element} = await render({ + session: {...session(), source_url: 'javascript:alert(1)'}, + joinUrl: 'https://user:password@example.test/meet', + googleCalendarUrl: 'data:text/html,test', + }); + expect(element.querySelector('a')).toBeNull(); + }); + + it('stops download and calendar callbacks after selected data is cleared', async () => { + const download = vi.fn(); + const calendarSettings = vi.fn(); + const {component, fixture, element} = await render({ + session: session(), + download, + calendarSettings, + }); + component.clear(); + fixture.detectChanges(); + component.download(); + component.calendarSettings(); + expect(download).not.toHaveBeenCalled(); + expect(calendarSettings).not.toHaveBeenCalled(); + expect(element.textContent.trim()).toBe(''); + }); + + it('blocks cancelled downloads and demo subscriptions at method level', async () => { + const download = vi.fn(); + const calendarSettings = vi.fn(); + const {component, element} = await render({ + session: {...session(), cancelled: true}, + demo: true, + canManage: true, + download, + calendarSettings, + }); + component.download(); + component.calendarSettings(); + expect(download).not.toHaveBeenCalled(); + expect(calendarSettings).not.toHaveBeenCalled(); + expect(element.querySelector('f-teams-meeting-composer')).toBeNull(); + expect(element.querySelector('a[href^="https://calendar.google.com"]')).toBeNull(); + }); + + it('uses the explicit copy action and calendar settings only after clicks', async () => { + const download = vi.fn(); + const calendarSettings = vi.fn(); + const {component, element} = await render({session: session(), download, calendarSettings}); + expect(download).not.toHaveBeenCalled(); + expect(calendarSettings).not.toHaveBeenCalled(); + const button = Array.from(element.querySelectorAll('button')).find((item) => + item.textContent.includes('Download .ics'), + ); + button.click(); + expect(download).toHaveBeenCalledOnce(); + component.calendarSettings(); + 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 new file mode 100644 index 0000000000..f84bc4f339 --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.ts @@ -0,0 +1,130 @@ +import {CommonModule} from '@angular/common'; +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'; + +export interface UnitHubDetailsData { + unitCode: string; + unitName: string; + demo: boolean; + announcement?: Readonly; + session?: Readonly; + joinUrl?: string | null; + googleCalendarUrl?: string | null; + canManage: boolean; + download?: () => void; + calendarSettings?: () => void; +} + +@Component({ + selector: 'f-unit-hub-details', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatIconModule, + HubMarkdownPipe, + TeamsMeetingComposerComponent, + ], + templateUrl: './unit-hub-details.component.html', + styleUrl: './unit-hub-details.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class UnitHubDetailsComponent { + readonly deviceTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + 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 { + return this.content?.announcement?.title ?? this.content?.session?.title ?? ''; + } + + get cancelled(): boolean { + return this.content?.session?.cancelled === true; + } + + get sourceLabel(): string { + const announcement = this.content?.announcement; + return announcement?.managed_externally || announcement?.source_provider === 'microsoft_teams' + ? 'Microsoft Teams' + : 'OnTrack'; + } + + get sessionKind(): string { + return ( + SESSION_KINDS.find((kind) => kind.value === this.content?.session?.kind)?.label ?? 'Session' + ); + } + + close(): void { + this.dialogRef.close(); + } + + /** Remove the selected record immediately when the parent account/unit/mode changes. */ + clear(): void { + this.content = null; + this.status = ''; + } + + download(): void { + if (!this.content?.session || this.content.session.cancelled) { + return; + } + this.content.download?.(); + this.status = this.content.demo ? 'DEMO calendar copy requested.' : 'Calendar copy requested.'; + } + + calendarSettings(): void { + if (!this.content || this.content.demo) { + return; + } + this.content.calendarSettings?.(); + this.close(); + } +} diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html new file mode 100644 index 0000000000..4ca8aafb2a --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.html @@ -0,0 +1,1213 @@ +
+ Home +
+
+

Your learning, together

+

Unit Hub

+

Announcements, HelpHubs and classes for the units you belong to.

+
+
+ @if (showFeed) { + + Showing updates for + + All my units + @for (unit of units; track unit.id) { + {{ unit.code }} · {{ unit.name }} + } + + + } +
+ + @if (manageableUnits.length) { + + + + } + Get notified + + + @if (manageableUnits.length) { + + + + } + + + Get notified + + +
+
+
+ + @if (demo.enabled) { + + } + +

{{ status }}

+ + @if (loading) { +
+ Loading your unit updates… + + +
+ } @else if (loadError) { + + } @else if (managing) { +
+
+
+

Manage unit updates

+

+ Publish an announcement or a class with its joining details. Changes are visible to + members of this unit. +

+
+
+ +
+ +

+ @if (managedTeamsConfigured) { + Automatic Teams announcements are configured for this unit. Posts appear after a + successful update. Change imported posts in Teams; they cannot be edited here. + } @else { + Publish updates here or link to their original Teams post. Your university can connect + this unit's Teams announcements through administrator setup. Microsoft sign-in alone + does not turn that connection on. + } + Session schedules are maintained here. +

+
+ @if (staffError) { + + } + @if (staffLoading) { +

Loading drafts and published content…

+ } + @if (!staffLoading && canManageSelected) { +
+ +
+ @if (editor) { +
+

+ {{ editingId ? 'Edit' : 'New' }} + {{ editor }} +

+ @if (editor === 'announcement') { +
+
+
+

+ Details +

+ + +
+

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

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

+ Visibility +

+ + + +

+ Leave Publish unchecked to save a draft that students cannot see. +

+
+
+ +
+ } @else { +
+
+
+

+ Details +

+ + + +
+

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

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

+ When +

+
+ + + + + @if (sessionForm.controls.recurrence.value === 'weekly') { + + } +
+

+ Enter both times in the named time zone. Weekly sessions keep this local time + when daylight saving changes. +

+
+
+

+ Where +

+ + + +
+
+

+ Visibility +

+ + @if (editingId) { + + } +

+ Leave Publish unchecked for a draft. Editing a repeating session changes the + whole series. +

+
+
+ +
+ + } + + @if (formError) { + + } +
+ +
+
+
+ } + @if (deleteCandidate) { + + } +
+
+

+ Announcements +

+ @for (row of staffAnnouncements; track row.id) { +
+
+ {{ row.title }}{{ row.published_at ? 'Published or scheduled' : 'Draft' + }}{{ row.pinned ? ' · Pinned' : '' + }}{{ isManagedExternally(row) ? ' · From Teams' : '' }} +
+
+ @if (isManagedExternally(row)) { + @if (safeUrl(row.source_url)) { + Manage in Teams + } @else { + Managed in Teams + } + } @else { + + + } +
+
+ } @empty { +

No announcements yet. Create a draft to get started.

+ } +
+
+

+ Sessions +

+ @for (row of staffSessions; track row.id) { +
+
+ {{ row.title }}{{ row.cancelled ? 'Cancelled' : row.published ? 'Published' : 'Draft' }} · + {{ row.start_at | date: 'medium' + }}{{ row.recurrence === 'weekly' ? ' · Weekly' : '' }} +
+
+ + @if (!row.cancelled) { + + } +
+
+ } @empty { +

No sessions yet. Add a HelpHub, lecture or class.

+ } +
+
+ } +
+ } @else if (!units.length) { +
+ +

No active units to show

+

+ Your announcements and sessions will appear here when you are enrolled in an active unit, or + assigned to teach one. +

+
+ } @else { +
+ +
+ @if (unavailableUnit) { + + } +
+ @if (tab !== 'sessions') { +
+
+ +

Announcements

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

+ Showing the latest 100 announcements. Check the original unit channel for older + updates. +

+ } + @for (announcement of visibleAnnouncements; track announcement.id) { + @let unread = isUnread(announcement); +
+
+ @if (unread) { + New + } + {{ unitCode(announcement.unit_id) }} + @if (announcement.pinned) { + Pinned + } + @if (isManagedExternally(announcement)) { + From Teams + } + @if (announcement.author_name) { + {{ announcement.author_name }} + } + + @if (unread) { + + } +
+
+

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

+

{{ announcement.body | hubPlainText }}

+
+
+ + @if (safeUrl(announcement.source_url)) { + Open original post + } +
+
+ } @empty { +
+ +

You're up to date

+

No announcements have been published for these units.

+ @if (manageableUnits.length) { + + } +
+ } + @if (announcements.length > announcementPage) { + + } +
+ } + @if (tab !== 'announcements') { +
+
+ +

Upcoming

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

+ HelpHubs & classes, times shown in {{ deviceTimeZone }}. + @if (feed.window_end) { + Scheduled sessions through {{ feed.window_end | date: 'd MMM yyyy' }}. + } +

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

+ + @if (group.label) { + {{ group.label }} + } + {{ group.date | date: 'EEEE, d MMMM' }} +

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

+ Cancelled: this session will + not run +

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

+ @switch (when.state) { + @case ('cancelled') { + Cancelled: + } + @case ('now') { + Happening now: + } + @case ('soon') { + Starts in {{ when.minutes }} min: + } + } + +

+
+ {{ session.start_at | date: 'h:mm a' }} – + {{ session.end_at | date: 'h:mm a' }} + @if ( + (session.start_at | date: 'yyyyMMdd') !== + (session.end_at | date: 'yyyyMMdd') + ) { + · ends {{ session.end_at | date: 'd MMM' }} + } + + {{ kindLabel(session.kind) }} + @if (session.location) { + {{ session.location }} + } @else if (joinUrl(session)) { + Online + } + @if (session.recurrence === 'weekly') { + Part of a weekly series + } +
+ @if (session.description) { +

{{ session.description | hubPlainText }}

+ } + @if (!session.cancelled) { + @if (demo.enabled && !joinUrl(session)) { +

+ No host meeting linked. Add a meeting in demo controls. +

+ } @else if (!demo.enabled && !joinUrl(session) && !session.location) { +

+ Joining details will be added by your teaching team. +

+ } + } +
+ @if (when.state === 'now' && joinUrl(session)) { + {{ demo.enabled ? 'Join hosted demo' : 'Join session' }} + } + + @if (!session.cancelled && when.state !== 'now' && joinUrl(session)) { + {{ demo.enabled ? 'Join hosted demo' : 'Join session' }} + } + @if (!session.cancelled) { + {{ + demo.enabled + ? 'Preview DEMO in Google Calendar' + : 'Add to Google Calendar' + }} + + } + @if (safeUrl(session.source_url)) { + View original details + } +
+
+ } +
+ } @empty { +
+ +

No sessions scheduled yet

+

Your teaching team can add HelpHubs, lectures and other classes here.

+ @if (manageableUnits.length) { + + } +
+ } + @if (sessionGroups.length > sessionDayPage) { + + } + +
+ } +
+ } + @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 }}

    +
  • + } +
+
+ } +
+ } +
+ + + } +
diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss new file mode 100644 index 0000000000..d10164e213 --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.scss @@ -0,0 +1,1225 @@ +// Spacing scale: 4 / 8 / 12 / 16 / 24 / 32 / 48. Colours come only from --ot-* tokens. +@use './hub-card' as hub; + +@include hub.card-theme; + +:host { + display: block; + color: var(--ot-color-text); + --hub-space-1: 4px; + --hub-space-2: 8px; + --hub-space-3: 12px; + --hub-space-4: 16px; + --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; + margin: 0 auto; + padding: var(--hub-space-5) var(--hub-space-6) var(--hub-space-7); + box-sizing: border-box; +} +h1, +h2, +h3, +h4, +p { + margin: 0; +} +h1 { + font-size: clamp(1.75rem, 3.5vw, 2.25rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.02em; +} +h2 { + font-size: 1.15rem; + font-weight: 650; + line-height: 1.35; +} +h3, +h4 { + font-size: 1.02rem; + font-weight: 650; + line-height: 1.4; +} +p { + line-height: 1.6; +} +a { + color: var(--ot-color-link); + text-decoration: underline; + text-underline-offset: 3px; +} +a, +button, +input, +select, +textarea { + touch-action: manipulation; + &:focus-visible { + outline: 3px solid var(--ot-color-focus); + outline-offset: 2px; + } +} +button { + cursor: pointer; +} +button:disabled { + cursor: default; +} +mat-icon { + flex-shrink: 0; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +// ---- Header ---- +.back-link { + display: inline-flex; + align-items: center; + gap: var(--hub-space-1); + margin-bottom: var(--hub-space-4); + font-size: 0.9rem; + text-decoration: none; + border-radius: var(--ot-radius-xs); + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } + &:hover { + text-decoration: underline; + } +} +.hub-heading { + // title and unit picker share the top row, the actions sit on their own row + // under the title, so nothing floats or wraps into a ragged edge + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'title picker' + 'actions actions'; + align-items: end; + gap: var(--hub-space-4) var(--hub-space-5); + margin-bottom: var(--hub-space-5); +} +.hub-title { + grid-area: title; + min-width: 0; + max-width: 620px; + display: flex; + flex-direction: column; + gap: var(--hub-space-1); +} +.eyebrow { + color: var(--ot-color-link); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.hub-subtitle { + color: var(--ot-color-text-muted); + font-size: 0.95rem; +} +.heading-actions { + display: contents; +} +.action-row { + grid-area: actions; + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + align-items: center; + gap: var(--hub-space-2); +} +// 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; +} + +// ---- Notices and status ---- +// one callout style for every notice: leading icon, copy beside it +.notice { + @include hub.callout(page); + margin-bottom: var(--hub-space-5); + p { + margin: 0; + } + .notice-body { + display: flex; + flex-direction: column; + gap: var(--hub-space-2); + min-width: 0; + } +} +.error-notice, +.delete-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)); + } +} +.info-notice { + color: var(--ot-color-text); +} +.status:empty { + display: none; +} +.status { + padding: var(--hub-space-3) var(--hub-space-4); + margin-bottom: var(--hub-space-4); + border-radius: var(--ot-radius-sm); + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); + font-weight: 600; +} + +// ---- Fields ---- +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: var(--hub-space-4); + font-size: 0.85rem; + font-weight: 600; +} +.field input, +.field select, +.field textarea { + min-width: 0; + width: 100%; + box-sizing: border-box; + // Muted text, not the border token: that is 1.6:1 in dark, too faint for a field edge. + border: 1px solid var(--ot-color-text-muted); + border-radius: var(--ot-radius-sm); + min-height: 44px; + padding: 10px 12px; + font: inherit; + font-weight: 400; + background: var(--ot-color-surface); + color: inherit; +} +.field textarea { + resize: vertical; +} +.unit-filter { + grid-area: picker; + justify-self: end; + width: min(100%, 300px); + margin: 0; + // sit on the same line as the action buttons + ::ng-deep .mat-mdc-text-field-wrapper { + height: 44px; + } + ::ng-deep .mat-mdc-form-field-infix { + min-height: 44px; + padding-block: 10px; + } +} + +// ---- Tabs ---- +.hub-filters { + display: flex; + margin-bottom: var(--hub-space-5); +} +.view-switch { + display: flex; + flex-wrap: wrap; + gap: var(--hub-space-1); + padding: var(--hub-space-1); + border: 1px solid var(--hub-card-border); + border-radius: var(--ot-radius-pill); + background: var(--ot-color-surface); +} +.view-switch button { + min-height: 40px; + padding: var(--hub-space-2) var(--hub-space-4); + border: 0; + border-radius: var(--ot-radius-pill); + background: transparent; + color: var(--ot-color-text); + font: inherit; + font-size: 0.9rem; + &:hover { + background: var(--ot-color-hover); + } +} +.view-switch button[aria-pressed='true'] { + background: var(--ot-color-primary); + color: var(--ot-color-on-primary); + font-weight: 650; +} + +// ---- Columns ---- +.hub-columns { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); + gap: var(--hub-space-5) var(--hub-space-6); + align-items: start; +} +.hub-columns > * { + min-width: 0; +} +.hub-columns.single-column { + grid-template-columns: minmax(0, 1fr); + max-width: 820px; +} +.section-heading { + display: flex; + align-items: center; + gap: var(--hub-space-2); + margin-bottom: var(--hub-space-4); + > mat-icon { + color: var(--ot-color-link); + } +} +.count { + min-width: 1.5em; + padding: 2px var(--hub-space-2); + border-radius: var(--ot-radius-pill); + background: var(--ot-color-surface-raised); + border: 1px solid var(--hub-card-border); + font-size: 0.78rem; + font-weight: 600; + text-align: center; +} + +// ---- Cards ---- +.hub-card { + @include hub.card(page); + position: relative; + display: flex; + flex-direction: column; + gap: var(--hub-space-3); + padding: var(--hub-space-4) var(--hub-space-5); + margin-bottom: var(--hub-space-3); + border-radius: var(--ot-radius-lg); + box-shadow: var(--ot-elevation-1); + overflow-wrap: anywhere; +} +// The whole card opens its details: the title button's ::after stretches over the +// card, and the card's own links and buttons sit above that hit area. +.announcement-card, +.session-card, +.up-next { + position: relative; + isolation: isolate; + a, + button:not(.details-title) { + position: relative; + z-index: 2; + } +} +.announcement-card:hover, +.session-card:hover { + border-color: var(--ot-color-link); +} +.card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hub-space-2) var(--hub-space-3); + font-size: 0.75rem; +} +// author and date sit in the meta row, each with its own icon +.meta-fact { + display: inline-flex; + align-items: center; + gap: var(--hub-space-1); + min-width: 0; + color: var(--ot-color-text-muted); + font-size: 0.8rem; + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + color: var(--ot-color-link); + } +} +.card-body { + display: flex; + flex-direction: column; + gap: var(--hub-space-1); + min-width: 0; +} +.unit-badge { + padding: 2px var(--hub-space-2); + border-radius: var(--ot-radius-xs); + background: var(--ot-color-surface-raised); + border: 1px solid var(--hub-card-border); + color: var(--ot-color-text); + font-weight: 700; + letter-spacing: 0.02em; +} +.chip { + display: inline-flex; + align-items: center; + gap: var(--hub-space-1); + padding: 2px var(--hub-space-2); + border-radius: var(--ot-radius-pill); + font-weight: 650; + line-height: 1.6; + mat-icon { + width: 14px; + height: 14px; + font-size: 14px; + } +} +.chip-pinned { + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); +} +.chip-neutral { + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); + border: 1px solid var(--hub-card-border); +} +.kind-chip { + background: var(--ot-status-ready-for-feedback); + color: var(--ot-status-ready-for-feedback-on); + &[data-tone='helphub'] { + background: var(--ot-status-discuss); + color: var(--ot-status-discuss-on); + } + &[data-tone='lecture'] { + 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); + } +} +.preserve-lines { + white-space: pre-wrap; +} +.card-preview { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + font-size: 0.92rem; +} +.details-title { + // The card, rather than this button, contains the stretched hit area. + position: static; + display: block; + width: 100%; + padding: 2px 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + overflow-wrap: anywhere; + border-radius: var(--ot-radius-xs); + &:hover { + color: var(--ot-color-link); + text-decoration: underline; + text-underline-offset: 4px; + } +} +.announcement-card .details-title:focus-visible, +.session-card .details-title:focus-visible, +.up-next .details-title:focus-visible { + outline: none; +} +.announcement-card .details-title::after, +.session-card .details-title::after, +.up-next .details-title::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + border-radius: var(--ot-radius-lg); +} +.announcement-card .details-title:focus-visible::after, +.session-card .details-title:focus-visible::after, +.up-next .details-title:focus-visible::after { + outline: 3px solid var(--ot-color-focus); + outline-offset: 3px; +} +// every card action sits in one footer row on the content inset, same outlined style +.card-actions { + @include hub.action-row; + padding-top: var(--hub-space-1); +} + +// ---- Upcoming ---- +.notice.time-note { + margin-bottom: var(--hub-space-4); + padding: var(--hub-space-2) var(--hub-space-3); + font-size: 0.82rem; + > mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } +} +.day-group + .day-group { + margin-top: var(--hub-space-5); +} +.day-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hub-space-2); + margin-bottom: var(--hub-space-2); + color: var(--ot-color-text-muted); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + color: var(--ot-color-link); + } +} +.day-label { + color: var(--ot-color-text); + font-weight: 750; +} +.session-card { + padding: var(--hub-space-4); +} +// time, type and place as one compact row that wraps +.facts-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hub-space-2) var(--hub-space-4); + font-size: 0.86rem; + .chip { + font-size: 0.75rem; + } +} +.fact { + display: inline-flex; + align-items: flex-start; + gap: var(--hub-space-1); + min-width: 0; + color: var(--ot-color-text); + line-height: 1.5; + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + margin-top: 1px; + color: var(--ot-color-link); + } +} +.action-note.help-text { + margin: 0; + color: var(--ot-color-text-muted); +} +// 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, + .fact { + color: var(--ot-color-text-muted); + } +} +.cancelled-banner { + @include hub.cancelled-banner; +} +.help-text { + margin: var(--hub-space-2) 0; + font-size: 0.84rem; + line-height: 1.5; +} +.notice.calendar-note { + margin: var(--hub-space-5) 0 0; + .notice-body button { + align-self: flex-start; + } +} + +// ---- Empty, error and loading states ---- +.state-panel, +.empty-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--hub-space-2); + text-align: center; +} +.state-panel { + max-width: 560px; + margin: var(--hub-space-6) auto; + padding: var(--hub-space-7) var(--hub-space-5); + @include hub.card(page); + border-radius: var(--ot-radius-lg); + p { + color: var(--ot-color-text-muted); + margin-bottom: var(--hub-space-3); + } +} +.empty-card { + padding: var(--hub-space-6) var(--hub-space-5); + border-style: dashed; + box-shadow: none; + p { + color: var(--ot-color-text-muted); + font-size: 0.9rem; + } + button { + margin-top: var(--hub-space-2); + } +} +.state-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + margin-bottom: var(--hub-space-1); + border-radius: var(--ot-radius-circle); + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); +} +.error-state .state-icon { + background: var(--ot-color-error); + color: var(--ot-color-on-error); +} +.skeleton-card { + box-shadow: none; +} +.skeleton { + border-radius: var(--ot-radius-xs); + background: var(--ot-color-divider); + animation: hub-pulse 1.4s ease-in-out infinite; +} +.skeleton-heading { + width: 40%; + height: 22px; + margin-bottom: var(--hub-space-4); +} +.skeleton-chip { + width: 64px; + height: 18px; + border-radius: var(--ot-radius-pill); +} +.skeleton-title { + width: 70%; + height: 20px; + margin: var(--hub-space-3) 0; +} +.skeleton-line { + width: 100%; + height: 12px; + margin-top: var(--hub-space-2); + &.short { + width: 55%; + } +} +@keyframes hub-pulse { + 50% { + opacity: 0.45; + } +} +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} +.hub-footer { + margin-top: var(--hub-space-5); + color: var(--ot-color-text-muted); + font-size: 0.8rem; +} +.hub-essentials { + margin-bottom: var(--hub-space-5); +} +.show-more.mat-mdc-button-base { + width: 100%; + margin-top: var(--hub-space-2); +} + +// ---- Manage panel ---- +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hub-space-2); +} +.manage-panel { + @include hub.card(page); + padding: var(--hub-space-5); + border-radius: var(--ot-radius-lg); + .section-heading p { + margin-top: var(--hub-space-1); + color: var(--ot-color-text-muted); + } + .field.manage-unit { + max-width: 420px; + } + .notice { + // inside the panel card, so the callout takes the raised surface + background: var(--ot-color-surface-raised); + margin-bottom: var(--hub-space-4); + } +} +.manage-actions { + margin: var(--hub-space-5) 0; +} +.editor { + max-width: 820px; + margin: var(--hub-space-5) 0; +} +.editor-heading, +.list-heading { + @include hub.icon-heading; + margin-bottom: var(--hub-space-4); +} +// fieldset-like groups: Details, When, Where, Visibility +.form-group { + @include hub.card(surface); + padding: var(--hub-space-4); + margin-bottom: var(--hub-space-4); + min-width: 0; + > .help-text:last-child { + margin-bottom: 0; + } + > .field:last-child { + margin-bottom: 0; + } +} +.group-heading { + @include hub.label; + margin-bottom: var(--hub-space-3); +} +.editor-actions { + @include hub.action-row; + margin-bottom: var(--hub-space-4); +} +.manage-panel .notice .actions, +.delete-notice .actions { + @include hub.action-row; +} +fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0 var(--hub-space-4); +} +.check-field { + display: flex; + align-items: center; + gap: var(--hub-space-2); + min-height: 44px; +} +.check-field input { + width: 20px; + height: 20px; + accent-color: var(--ot-color-primary); +} +.management-lists { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--hub-space-5); + margin-top: var(--hub-space-5); +} +.management-lists > section { + min-width: 0; +} +// each existing item is its own card: title and status, then an actions footer +.managed-row { + @include hub.card(surface); + display: flex; + flex-direction: column; + gap: var(--hub-space-3); + padding: var(--hub-space-3) var(--hub-space-4); + margin-bottom: var(--hub-space-2); + overflow-wrap: anywhere; +} +.managed-body { + display: flex; + flex-direction: column; + gap: var(--hub-space-1); + min-width: 0; + span { + color: var(--ot-color-text-muted); + font-size: 0.8rem; + } +} +.managed-actions { + @include hub.action-row; +} +.managed-note { + color: var(--ot-color-text-muted); + font-size: 0.84rem; +} + +// ---- Responsive ---- +@media (max-width: 900px) { + .hub-columns, + .management-lists { + grid-template-columns: minmax(0, 1fr); + } + // Upcoming comes first when the columns stack. + .sessions-column { + order: -1; + } +} +@media (max-width: 720px) { + .wide-only { + display: none; + } + .narrow-only { + display: inline-flex; + } + // phones: title on top, then the picker and the Actions menu on one row + .hub-heading { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'title title' + 'picker actions'; + } + .unit-filter { + justify-self: stretch; + width: auto; + } + .action-row { + justify-content: flex-end; + } +} +@media (max-width: 599px) { + .unit-hub { + padding: var(--hub-space-4) var(--hub-space-4) var(--hub-space-6); + } + .hub-heading { + margin-bottom: var(--hub-space-4); + } + .view-switch { + width: 100%; + flex-wrap: nowrap; + } + .view-switch button { + flex: 1; + min-width: 0; + padding: var(--hub-space-2) var(--hub-space-1); + font-size: 0.8rem; + } + .hub-card, + .session-card { + padding: var(--hub-space-4); + } + .form-grid { + grid-template-columns: 1fr; + } + .manage-panel { + padding: var(--hub-space-4); + } + .form-group { + padding: var(--hub-space-3); + } + .facts-row { + flex-direction: column; + align-items: flex-start; + gap: var(--hub-space-1); + } + .state-panel { + padding: var(--hub-space-6) var(--hub-space-4); + } +} + +// the composer sits inside the manage panel card here, so it takes the raised surface +.editor f-teams-meeting-composer { + --hub-composer-surface: var(--ot-color-surface-raised); +} + +// ---- Read status (student feed only) ---- +.section-heading .mark-all-read.mat-mdc-button-base { + margin-left: auto; +} +.announcement-card.is-unread { + // unread: the whole card takes a light primary wash and edge + @include hub.solid-card(var(--ot-color-primary), var(--ot-color-on-primary)); + box-shadow: var(--ot-elevation-1); + .details-title { + font-weight: 750; + } +} +.announcement-card.is-read { + .details-title { + font-weight: 500; + } +} +.chip-new { + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); +} +.new-dot { + width: 8px; + height: 8px; + 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. +// 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) { + .section-heading { + flex-wrap: wrap; + } +} + +// ---- Markdown ---- +.format-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--hub-space-2) var(--hub-space-3); + margin: 0 0 var(--hub-space-3); + .format-hint { + margin: 0; + color: var(--ot-color-text-muted); + } +} +// the same segmented control as the feed filter, at field scale +.write-preview { + display: inline-flex; + gap: var(--hub-space-1); + padding: 2px; + border: 1px solid var(--hub-card-border); + border-radius: var(--ot-radius-md); + background: var(--ot-color-surface); + button { + display: inline-flex; + align-items: center; + gap: var(--hub-space-1); + min-height: 36px; + padding: 0 var(--hub-space-3); + border: 0; + border-radius: var(--ot-radius-sm); + background: transparent; + color: var(--ot-color-text); + font: inherit; + font-size: 0.84rem; + &:hover { + background: var(--ot-color-hover); + } + &[aria-pressed='true'] { + background: var(--ot-color-primary); + color: var(--ot-color-on-primary); + font-weight: 650; + } + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + } +} +.markdown-preview { + @include hub.card(page); + @include hub.markdown; + max-width: none; + min-height: 96px; + margin-bottom: var(--hub-space-4); + padding: var(--hub-space-3) var(--hub-space-4); +} +@media (max-width: 599px) { + .write-preview { + width: 100%; + button { + flex: 1; + justify-content: center; + min-height: 44px; + } + } +} + +// ---- Session timing ---- +// Tints at 14% on the surface with the tone pulled 40% toward the text colour clear 4.5:1: +// success 6.7:1 light, 5.4:1 dark; warning 6.7:1 light, 5.9:1 dark. +.chip-now { + background: color-mix(in srgb, var(--ot-color-success) 14%, var(--ot-color-surface)); + color: color-mix(in srgb, var(--ot-color-success) 60%, var(--ot-color-text)); +} +.chip-soon { + background: color-mix(in srgb, var(--ot-color-warning) 14%, var(--ot-color-surface)); + color: color-mix(in srgb, var(--ot-color-warning) 60%, var(--ot-color-text)); +} +.chip-today { + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); +} +.live-dot { + width: 8px; + height: 8px; + border-radius: var(--ot-radius-circle); + background: currentColor; + animation: hub-live 2s ease-in-out infinite; +} +@keyframes hub-live { + 50% { + opacity: 0.35; + } +} +@media (prefers-reduced-motion: reduce) { + .live-dot { + animation: none; + } +} +.session-card[data-timing='now'] { + @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.solid-card(var(--ot-color-primary), var(--ot-color-on-primary)); + box-shadow: var(--ot-elevation-1); +} + +// ---- Up next ---- +.up-next { + @include hub.card(page); + display: flex; + flex-direction: column; + gap: var(--hub-space-2); + margin-bottom: var(--hub-space-4); + padding: var(--hub-space-4); + border-radius: var(--ot-radius-lg); + @include hub.tinted-card(var(--ot-color-link), 4%, 22%); + box-shadow: none; + overflow-wrap: anywhere; + &[data-timing='now'] { + @include hub.tinted-card(var(--ot-color-success), 7%, 32%); + } + h3 { + font-size: 1rem; + } +} +.up-next-label { + @include hub.label; +} +.up-next-when { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hub-space-1) var(--hub-space-3); + font-size: 0.86rem; +} +.up-next-relative { + font-weight: 650; +} + +// ---- What your task statuses mean ---- +// Reference, not something to read through. It stays closed until asked for, and +// each row is one status with the reason it is set and what to do about it. + +.status-guide { + margin-block-start: 24px; +} + +.status-guide__toggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 14px 16px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md); + background: var(--ot-color-surface); + color: var(--ot-color-text); + font: inherit; + font-size: 0.95rem; + font-weight: 600; + text-align: start; + cursor: pointer; + transition: + transform 140ms cubic-bezier(0.23, 1, 0.32, 1), + background-color 160ms ease; + + &:hover { + background: var(--ot-color-surface-raised); + } + + &:active { + transform: scale(0.995); + } + + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; + } + + mat-icon { + width: 20px; + height: 20px; + color: var(--ot-color-text-muted); + font-size: 20px; + } +} + +.status-guide__chevron { + margin-inline-start: auto; +} + +.status-guide__groups { + display: flex; + flex-direction: column; + gap: 20px; + margin-block-start: 12px; + animation: status-guide-open 200ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.status-guide__group-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 10px; + color: var(--ot-color-text-muted); + font-size: 0.74rem; + font-weight: 650; + letter-spacing: 0.05em; + text-transform: uppercase; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +.status-guide__list { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + margin: 0; + padding: 0; + list-style: none; +} + +.status-guide__row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 0; + padding: 14px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md); + background: var(--ot-color-surface); +} + +// The same fill the status carries everywhere else, so the pill here is +// recognisable as the one on the task. +.status-guide__pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 11px 5px 8px; + border-radius: 999px; + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); + font-size: 0.8rem; + font-weight: 600; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + + @each $tone in not-started, working-on-it, need-help, ready-for-feedback, fix-and-resubmit, redo, + attention-required, discuss, rediscuss, demonstrate, assess-in-portfolio, complete, + feedback-exceeded, time-exceeded, fail + { + &[data-tone='#{$tone}'] { + background: var(--ot-status-#{$tone}); + color: var(--ot-status-#{$tone}-on); + } + } +} + +.status-guide__reason { + margin: 0; + color: var(--ot-color-text); + font-size: 0.875rem; + line-height: 1.5; +} + +.status-guide__action { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.5; +} + +@keyframes status-guide-open { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .status-guide__groups { + animation: none; + } + + .status-guide__toggle { + transition: none; + } +} diff --git a/src/app/unit-hub/unit-hub.component.spec.ts b/src/app/unit-hub/unit-hub.component.spec.ts new file mode 100644 index 0000000000..784473056a --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.spec.ts @@ -0,0 +1,649 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {Router, provideRouter} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {Subject, of, throwError} from 'rxjs'; +import {UserService} from 'src/app/api/services/user.service'; +import {routes} from 'src/app/app.routes'; +import {roleWhitelistGuard} from 'src/app/common/guards/role-whitelist.guard'; +import {CalendarModalService} from 'src/app/common/modals/calendar-modal/calendar-modal.service'; +import {DEMO_TOOLS_AVAILABLE, DemoModeStore} from 'src/app/demo/demo-mode.store'; +import {unitHubDemo} from './unit-hub-demo.fixtures'; +import {UnitHubDetailsComponent} from './unit-hub-details.component'; +import {UnitHubComponent} from './unit-hub.component'; +import {UnitHubService, scopeHubFeed} from './unit-hub.service'; + +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 feed = () => scopeHubFeed(unitHubDemo(new Date('2026-09-14T06:00:00Z'))); + +describe('Unit Hub route, forms and rendered content', () => { + let service: { + feed: ReturnType; + announcements: ReturnType; + sessions: ReturnType; + saveAnnouncement: ReturnType; + saveSession: ReturnType; + remove: ReturnType; + }; + let calendars: {show: ReturnType}; + let demo: DemoModeStore; + beforeEach(() => { + sessionStorage.clear(); + Object.defineProperty(globalThis, 'localStorage', {configurable: true, value: memoryStorage()}); + service = { + feed: vi.fn().mockImplementation(() => of(feed())), + announcements: vi.fn().mockReturnValue(of([])), + sessions: vi.fn().mockReturnValue(of([])), + saveAnnouncement: vi.fn().mockReturnValue(of({id: 5})), + saveSession: vi.fn().mockReturnValue(of({id: 5})), + remove: vi.fn().mockReturnValue(of({success: true})), + }; + calendars = {show: vi.fn()}; + TestBed.configureTestingModule({ + providers: [ + provideRouter([{path: 'unit-hub', component: UnitHubComponent}]), + {provide: UnitHubService, useValue: service}, + {provide: CalendarModalService, useValue: calendars}, + {provide: DEMO_TOOLS_AVAILABLE, useValue: true}, + {provide: UserService, useValue: {currentUser: {id: 7}}}, + ], + }); + demo = TestBed.inject(DemoModeStore); + // The demo store only switches on for a loaded demo scenario. + demo.configureScenario('all-features', 111); + }); + afterEach(() => { + TestBed.inject(MatDialog).closeAll(); + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + async function open(url = '/unit-hub') { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl(url, UnitHubComponent); + harness.detectChanges(); + return {harness, component, element: harness.routeNativeElement as HTMLElement}; + } + + it('has a reachable lazy route restricted to recognised account roles', async () => { + const route = routes.find((item) => item.path === 'unit-hub'); + expect(route.loadComponent).toBeDefined(); + expect(route.canActivate).toContain(roleWhitelistGuard); + expect(route.data.roleWhitelist).toContain('Student'); + const {element} = await open(); + expect(element.querySelector('h1')?.textContent).toBe('Unit Hub'); + expect(element.textContent).toContain('Computer Systems HelpHub'); + expect(element.textContent).not.toContain('SIT102 HelpHub must stay hidden'); + expect(element.textContent).not.toContain('Manage updates'); + }); + + it('keeps the named hub section inside the app-owned main landmark', async () => { + const {element} = await open(); + expect(element.querySelector('main, [role="main"]')).toBeNull(); + const section = element.querySelector('section.unit-hub'); + expect(section.getAttribute('aria-labelledby')).toBe('unit-hub-title'); + expect(section.querySelector('#unit-hub-title').textContent).toBe('Unit Hub'); + }); + + it('unknown unit deep links reveal no other unit content and show a useful message', async () => { + const {component, element} = await open('/unit-hub?unit=102'); + expect(component.sessions).toEqual([]); + expect(component.announcements).toEqual([]); + expect(element.textContent).toContain('This unit is not available to your account'); + expect(element.textContent).not.toContain('Computer Systems HelpHub'); + }); + + it('switches update type from the visible controls', async () => { + const {harness, element} = await open(); + const button = Array.from(element.querySelectorAll('nav button')).find( + (item) => item.textContent?.trim() === 'Sessions', + ) as HTMLButtonElement; + button.click(); + harness.detectChanges(); + expect(element.querySelector('#announcements-title')).toBeNull(); + expect(element.querySelector('#sessions-title')).not.toBeNull(); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('renders announcement text without interpreting HTML', async () => { + const data = feed(); + data.announcements[0].body = ' Please review your tasks.'; + service.feed.mockReturnValue(of(data)); + const {element} = await open(); + expect(element.querySelector('.announcement-card img')).toBeNull(); + expect(element.textContent).toContain(''); + const link = element.querySelector('a[href^="https://calendar.google.com"]'); + expect(link?.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('clears failed feeds, offers retry and shows the genuine empty state after recovery', async () => { + service.feed + .mockReturnValueOnce(throwError(() => new Error('offline'))) + .mockReturnValueOnce(of({units: [], announcements: [], sessions: []})); + 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.trim().endsWith('Try again'), + ); + retry.click(); + harness.detectChanges(); + expect(element.textContent).toContain('No active units to show'); + }); + + it('demo Google drafts are marked fictional, omit joining links and never open subscription settings', async () => { + demo.setEnabled(true); + const {component, element} = await open(); + const link = element.querySelector( + 'a[href^="https://calendar.google.com"]', + ) as HTMLAnchorElement; + const url = new URL(link.href); + expect(url.searchParams.get('text')).toContain('DEMO'); + expect(url.searchParams.get('details')).toContain('FICTIONAL ONTRACK DEMO'); + expect(url.searchParams.get('details')).not.toContain('https://'); + expect(url.searchParams.get('location')).toBe('Fictional demo only'); + expect(element.querySelector('a[href*="example.invalid"]')).toBeNull(); + component.openCalendar(); + expect(calendars.show).not.toHaveBeenCalled(); + component.toggleManage(); + expect(service.announcements).not.toHaveBeenCalled(); + expect(service.sessions).not.toHaveBeenCalled(); + }); + + it('cancelled occurrences offer no join or export actions', async () => { + const data = feed(); + data.sessions = [{...data.sessions[0], cancelled: true}]; + service.feed.mockReturnValue(of(data)); + const {element} = await open(); + const card = element.querySelector('.session-card'); + expect(card.textContent).toContain('Cancelled'); + expect(card.querySelector('a[href^="https://calendar.google.com"]')).toBeNull(); + expect(card.querySelector('a[href*="example.invalid"]')).toBeNull(); + }); + + async function openManager() { + const data = feed(); + data.units[0].can_manage = true; + service.feed.mockReturnValue(of(data)); + const result = await open(); + result.component.toggleManage(); + result.harness.detectChanges(); + return result; + } + + it('shows configured Teams imports as read-only and links staff back to their source', async () => { + const data = feed(); + data.units[0].can_manage = true; + data.units[0].teams_sync = 'configured'; + const imported = { + ...data.announcements[0], + source_provider: 'microsoft_teams' as const, + managed_externally: true, + source_url: 'https://teams.microsoft.com/l/message/example', + }; + service.feed.mockReturnValue(of(data)); + service.announcements.mockReturnValue(of([imported])); + const {component, harness, element} = await open(); + component.toggleManage(); + harness.detectChanges(); + expect(element.textContent).toContain('Automatic Teams announcements are configured'); + expect( + element.querySelector('a[href="https://teams.microsoft.com/l/message/example"]')?.textContent, + ).toContain('Manage in Teams'); + expect(element.querySelector(`button[aria-label="Edit ${imported.title}"]`)).toBeNull(); + component.editAnnouncement(imported); + expect(component.editor).toBeNull(); + component.deleteCandidate = {id: imported.id, title: imported.title, kind: 'announcements'}; + component.confirmRemove(); + expect(service.remove).not.toHaveBeenCalled(); + }); + + it('does not imply that Microsoft sign-in alone enables a Teams connection', async () => { + const {element} = await openManager(); + const text = element.textContent.replace(/\s+/g, ' '); + expect(text).toContain('Microsoft sign-in alone does not turn that connection on.'); + expect(text).not.toContain('Automatic Teams announcements are configured'); + }); + + it('labels imported announcements for students without showing account or tenant details', async () => { + const data = feed(); + data.announcements[0].source_provider = 'microsoft_teams'; + service.feed.mockReturnValue(of(data)); + const {element} = await open(); + expect(element.querySelector('.announcement-card')?.textContent).toContain('From Teams'); + expect(element.textContent).not.toContain('Manage in Teams'); + }); + + it('staff can create a draft announcement only inside a manageable unit', async () => { + const {component, harness, element} = await openManager(); + component.editAnnouncement(); + harness.detectChanges(); + expect(element.querySelector('label[for="announcement-title"]')).not.toBeNull(); + component.announcementForm.patchValue({ + title: 'Week update', + body: 'Bring questions', + published: false, + }); + component.save(); + expect(service.saveAnnouncement).toHaveBeenCalledWith( + 111, + expect.objectContaining({title: 'Week update', published_at: null}), + undefined, + ); + component.managedUnitId = 102; + component.editSession(); + expect(component.editor).toBeNull(); + }); + + it('staff session form converts the chosen time zone and preserves weekly rules', async () => { + const {component} = await openManager(); + component.editSession(); + component.sessionForm.patchValue({ + title: 'HelpHub', + start_at: '2026-10-08T17:00', + end_at: '2026-10-08T18:00', + timezone: 'Australia/Melbourne', + recurrence: 'weekly', + recurrence_until: '2026-11-05', + published: true, + join_url: 'https://teams.microsoft.com/example', + }); + component.save(); + expect(service.saveSession).toHaveBeenCalledWith( + 111, + expect.objectContaining({ + start_at: '2026-10-08T06:00:00.000Z', + end_at: '2026-10-08T07:00:00.000Z', + timezone: 'Australia/Melbourne', + recurrence: 'weekly', + recurrence_until: '2026-11-05', + }), + undefined, + ); + }); + + it('invalid dates and unsafe links never reach the write endpoint', async () => { + const {component} = await openManager(); + component.editSession(); + component.sessionForm.patchValue({ + title: 'HelpHub', + start_at: '2026-10-04T02:30', + end_at: '2026-10-04T04:00', + }); + component.save(); + expect(component.formError).toContain('clocks change'); + expect(service.saveSession).not.toHaveBeenCalled(); + component.editAnnouncement(); + component.announcementForm.patchValue({ + title: 'Update', + body: 'Hello', + source_url: 'javascript:alert(1)', + }); + component.save(); + expect(component.formError).toContain('HTTPS'); + expect(service.saveAnnouncement).not.toHaveBeenCalled(); + }); + + it('failed saves retain entered content for correction', async () => { + service.saveAnnouncement.mockReturnValue(throwError(() => new Error('422'))); + const {component} = await openManager(); + component.editAnnouncement(); + component.announcementForm.patchValue({title: 'My draft', body: 'Keep my work'}); + component.save(); + expect(component.formError).toContain('Could not save'); + expect(component.announcementForm.controls.body.value).toBe('Keep my work'); + expect(component.saving).toBe(false); + }); + + it('keeps both unit selectors on the unit being shown or managed, including after a save', async () => { + const data = feed(); + data.units = [ + {...data.units[0], can_manage: true}, + {id: 222, code: 'SIT222', name: 'Other Unit', can_manage: true}, + ]; + service.feed.mockReturnValue(of(data)); + const {component, harness, element} = await open('/unit-hub?unit=222'); + // the feed filter is a mat-select now, so check what it shows + expect(component.selectedUnitId).toBe(222); + expect(element.querySelector('#hub-unit')?.textContent).toContain('SIT222'); + component.toggleManage(); + harness.detectChanges(); + expect((element.querySelector('#manage-unit') as HTMLSelectElement).value).toBe('222'); + const pendingFeed: Subject = new Subject(); + service.feed.mockReturnValue(pendingFeed); + component.editAnnouncement(); + component.announcementForm.patchValue({title: 'Week update', body: 'Bring questions'}); + component.save(); + harness.detectChanges(); + pendingFeed.next(data); + harness.detectChanges(); + expect(service.saveAnnouncement).toHaveBeenCalledWith(222, expect.anything(), undefined); + expect((element.querySelector('#manage-unit') as HTMLSelectElement).value).toBe('222'); + }); + + it('opens full plain announcement details from its title and restores focus on close', async () => { + const data = feed(); + data.announcements[0].body = 'Full announcement\n\nLast paragraph'; + service.feed.mockReturnValue(of(data)); + const {harness, element} = await open(); + const title = element.querySelector('.announcement-card .details-title') as HTMLButtonElement; + title.focus(); + title.click(); + harness.detectChanges(); + await harness.fixture.whenStable(); + const dialog = TestBed.inject(MatDialog).openDialogs[0]; + const details = dialog.componentInstance as UnitHubDetailsComponent; + expect(details.content.announcement.body).toContain('Last paragraph'); + const overlay = document.querySelector('mat-dialog-container'); + expect(overlay.textContent).toContain(''); + expect(overlay.querySelector('img')).toBeNull(); + expect(overlay.querySelector('a a, button a, a button, button button')).toBeNull(); + expect(overlay.getAttribute('aria-labelledby')).toBe('unit-hub-detail-title'); + expect(document.activeElement?.id).toBe('unit-hub-detail-title'); + (overlay.querySelector('button[aria-label="Close details"]') as HTMLButtonElement).click(); + harness.detectChanges(); + await harness.fixture.whenStable(); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + expect(document.activeElement).toBe(title); + }); + + it('only opens details from the current permitted feed and clears them on unit or demo changes', async () => { + const {component, harness} = await open(); + const dialogs = TestBed.inject(MatDialog); + component.openSession({...component.sessions[0], unit_id: 102}); + component.openAnnouncement({...component.announcements[0], unit_id: 102}); + expect(dialogs.openDialogs).toHaveLength(0); + component.openSession(component.sessions[0]); + const previous = dialogs.openDialogs[0].componentInstance as UnitHubDetailsComponent; + await harness.navigateByUrl('/unit-hub?unit=102', UnitHubComponent); + harness.detectChanges(); + await harness.fixture.whenStable(); + expect(previous.content).toBeNull(); + await vi.waitFor(() => expect(dialogs.openDialogs).toHaveLength(0)); + await harness.navigateByUrl('/unit-hub', UnitHubComponent); + component.openAnnouncement(component.announcements[0]); + const beforeMode = dialogs.openDialogs[0].componentInstance as UnitHubDetailsComponent; + demo.setEnabled(true); + harness.detectChanges(); + await harness.fixture.whenStable(); + expect(beforeMode.content).toBeNull(); + await vi.waitFor(() => expect(dialogs.openDialogs).toHaveLength(0)); + }); + + it('closes details on Escape and hides cancelled meeting actions', async () => { + const data = feed(); + data.sessions[0].cancelled = true; + service.feed.mockReturnValue(of(data)); + const {component, harness} = await open(); + component.openSession(component.sessions[0]); + harness.detectChanges(); + await harness.fixture.whenStable(); + const overlay = document.querySelector('mat-dialog-container'); + expect(overlay.textContent).toContain('Cancelled'); + expect(overlay.querySelector('a[href^="https://calendar.google.com"]')).toBeNull(); + expect(overlay.querySelector('f-teams-meeting-composer')).toBeNull(); + document.body.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Escape', keyCode: 27, bubbles: true}), + ); + harness.detectChanges(); + await harness.fixture.whenStable(); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + }); + + it('keeps Teams meeting preparation staff-only and derives instants without saving', async () => { + const {component, harness} = await openManager(); + component.editSession(); + expect(component.teamsDraft).toBeNull(); + component.sessionForm.patchValue({ + title: 'HelpHub', + start_at: '2026-10-08T17:00', + end_at: '2026-10-08T18:00', + }); + expect(component.teamsDraft.start_at).toBe('2026-10-08T06:00:00.000Z'); + expect(service.saveSession).not.toHaveBeenCalled(); + component.openSession(component.sessions[0]); + harness.detectChanges(); + expect(document.querySelector('mat-dialog-container f-teams-meeting-composer')).not.toBeNull(); + TestBed.inject(MatDialog).closeAll(); + await vi.waitFor(() => expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0)); + component.feed.units[0].can_manage = false; + component.openSession(component.sessions[0]); + harness.detectChanges(); + expect(component.teamsDraft).toBeNull(); + expect(document.querySelector('mat-dialog-container f-teams-meeting-composer')).toBeNull(); + }); + + it('uses only explicitly hosted demo links in joining and calendar details', async () => { + demo.setEnabled(true); + const data = feed(); + const hosted = { + ...data.sessions[0], + demo_hosted_join: true, + join_url: 'https://teams.microsoft.com/l/meetup-join/hosted-demo', + }; + data.sessions = [hosted]; + service.feed.mockReturnValue(of(data)); + const {component, harness, element} = await open(); + expect(element.textContent).toContain('Join hosted demo'); + const url = new URL(component.googleUrl(component.sessions[0], 'SIT111')); + expect(url.searchParams.get('details')).toContain(hosted.join_url); + expect(url.searchParams.get('details')).toContain( + 'Fictional schedule; uses a real host-provided meeting link.', + ); + component.openSession(component.sessions[0]); + harness.detectChanges(); + const details = TestBed.inject(MatDialog).openDialogs[0] + .componentInstance as UnitHubDetailsComponent; + expect(details.content.joinUrl).toBe(hosted.join_url); + expect(details.content.canManage).toBe(false); + expect(component.joinUrl({...hosted, demo_hosted_join: false})).toBeNull(); + expect(component.joinUrl({...hosted, join_url: 'javascript:alert(1)'})).toBeNull(); + expect(component.joinUrl({...hosted, cancelled: true})).toBeNull(); + }); + describe('announcement read status', () => { + const cards = (element: HTMLElement) => + Array.from(element.querySelectorAll('.announcement-card')); + + it('highlights unread announcements with a text chip and screen reader state', async () => { + const {component, element} = await open(); + const count = component.announcements.length; + expect(count).toBeGreaterThan(0); + expect(component.unreadCount).toBe(count); + const first = cards(element)[0]; + expect(first.classList).toContain('is-unread'); + expect(first.querySelector('.chip-new')?.textContent).toContain('New'); + expect(first.querySelector('h3 .visually-hidden')?.textContent).toContain('Unread'); + expect(first.querySelector('button.mark-read')?.getAttribute('aria-label')).toContain( + 'Mark as read', + ); + expect( + element.querySelector('.section-heading .count')?.textContent.replace(/\s+/g, ' '), + ).toContain(`${count} · ${count} new`); + }); + + it('marks an announcement read when its details open, and persists it per user', async () => { + const {component, harness, element} = await open(); + const title = cards(element)[0].querySelector('.details-title') as HTMLButtonElement; + title.click(); + harness.detectChanges(); + expect(component.isUnread(component.announcements[0])).toBe(false); + expect(cards(element)[0].classList).toContain('is-read'); + expect(cards(element)[0].querySelector('.chip-new')).toBeNull(); + expect(component.unreadCount).toBe(component.announcements.length - 1); + const stored = JSON.parse(localStorage.getItem('ontrack.unitHub.read.7')); + expect(Object.keys(stored)).toEqual([String(component.announcements[0].id)]); + }); + + it('marks one from its card button and all from the section heading', async () => { + const {component, harness, element} = await open(); + (cards(element)[1].querySelector('button.mark-read') as HTMLButtonElement).click(); + harness.detectChanges(); + expect(component.isUnread(component.announcements[1])).toBe(false); + const markAll = Array.from(element.querySelectorAll('button')).find((button) => + button.textContent.includes('Mark all as read'), + ); + markAll.click(); + harness.detectChanges(); + expect(component.unreadCount).toBe(0); + expect(element.querySelector('.chip-new')).toBeNull(); + expect( + Array.from(element.querySelectorAll('button')).some((button) => + button.textContent.includes('Mark all as read'), + ), + ).toBe(false); + }); + + it('does not show read status while staff manage content', async () => { + const {element} = await openManager(); + expect(element.querySelector('.chip-new')).toBeNull(); + expect(element.textContent).not.toContain('Mark all as read'); + }); + }); + it('shows card excerpts as plain text with no markdown symbols', async () => { + const data = feed(); + data.announcements[0].body = '## Week 3\n\n**Bring** your *questions*\n\n- one\n- two'; + service.feed.mockReturnValue(of(data)); + const {element} = await open(); + const preview = element.querySelector('.announcement-card .card-preview'); + expect(preview.textContent).toBe('Week 3 Bring your questions one two'); + expect(preview.textContent).not.toMatch(/[*#]/); + expect(preview.querySelector('strong, h3, li')).toBeNull(); + }); + + it('offers a Write and Preview toggle that renders the announcement body', async () => { + const {component, harness, element} = await openManager(); + component.editAnnouncement(); + component.announcementForm.patchValue({body: '**Bold** text'}); + harness.detectChanges(); + expect(element.textContent).toContain('Supports simple formatting'); + const preview = Array.from(element.querySelectorAll('.write-preview button')).find((button) => + button.textContent.includes('Preview'), + ) as HTMLButtonElement; + preview.click(); + harness.detectChanges(); + expect(element.querySelector('#announcement-body-preview strong')?.textContent).toBe('Bold'); + expect((element.querySelector('#announcement-body') as HTMLTextAreaElement).hidden).toBe(true); + 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(); + }); + 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 new file mode 100644 index 0000000000..a3f5995db7 --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.ts @@ -0,0 +1,771 @@ +import {CommonModule} from '@angular/common'; +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'; +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 {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 {buildStatusGuide} from './status-guide'; +import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; +import {TeamsMeetingDraft} from './teams-meeting-draft'; +import { + dateTimeInZone, + dateTimeToIso, + googleSessionUrl, + safeHttpsUrl, + sessionIcs, +} from './unit-hub-calendar'; +import {UnitHubDetailsComponent, UnitHubDetailsData} from './unit-hub-details.component'; +import { + HubUnit, + LearningSession, + SESSION_KINDS, + SessionKind, + UnitAnnouncement, + UnitHubFeed, +} from './unit-hub.models'; +import {UnitHubService} from './unit-hub.service'; + +@Component({ + selector: 'f-unit-hub', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatDialogModule, + MatIconModule, + MatMenuModule, + MatFormFieldModule, + MatSelectModule, + RouterLink, + StudyEssentialsComponent, + TeamsMeetingComposerComponent, + HubMarkdownPipe, + HubPlainTextPipe, + ], + templateUrl: './unit-hub.component.html', + styleUrl: './unit-hub.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class UnitHubComponent implements OnInit, OnDestroy { + readonly sessionKinds = SESSION_KINDS; + readonly deviceTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + readonly safeUrl = safeHttpsUrl; + feed: UnitHubFeed = {units: [], announcements: [], sessions: []}; + loading = true; + loadError = ''; + selectedUnitId = 0; + unavailableUnit = false; + tab: 'all' | 'announcements' | 'sessions' = 'all'; + // long feeds show a first page so the rest of the hub stays in reach + readonly announcementPage = 6; + readonly sessionDayPage = 4; + showAllAnnouncements = false; + showAllSessionDays = false; + managing = false; + managedUnitId = 0; + staffLoading = false; + staffError = ''; + staffAnnouncements: UnitAnnouncement[] = []; + staffSessions: LearningSession[] = []; + editor: 'announcement' | 'session' | null = null; + editingId?: number; + editingPublishedAt: string | null = null; + // Write / Preview for the markdown fields in the staff editor + announcementPreview = false; + sessionPreview = false; + saving = false; + formError = ''; + status = ''; + deleteCandidate: {id: number; title: string; kind: 'announcements' | 'sessions'} | null = null; + private subscriptions = new Subscription(); + private feedRequest?: Subscription; + 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 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; + private feedKey: string | null = null; + + readonly announcementForm; + readonly sessionForm; + + constructor( + private service: UnitHubService, + readonly demo: DemoModeStore, + private calendars: CalendarModalService, + private route: ActivatedRoute, + private router: Router, + formBuilder: FormBuilder, + private dialogs: MatDialog, + private readStatus: AnnouncementReadStore, + ) { + this.announcementForm = formBuilder.nonNullable.group({ + title: ['', [Validators.required, Validators.maxLength(200)]], + body: ['', [Validators.required, Validators.maxLength(20000)]], + source_url: [''], + pinned: [false], + published: [false], + expires_at: [''], + }); + this.sessionForm = formBuilder.nonNullable.group({ + title: ['', [Validators.required, Validators.maxLength(200)]], + description: ['', Validators.maxLength(20000)], + kind: ['helphub' as SessionKind], + start_at: ['', Validators.required], + end_at: ['', Validators.required], + timezone: ['Australia/Melbourne', Validators.required], + join_url: [''], + location: ['', Validators.maxLength(300)], + source_url: [''], + published: [false], + cancelled: [false], + recurrence: ['none' as 'none' | 'weekly'], + recurrence_until: [''], + }); + } + + ngOnInit(): void { + this.subscriptions.add( + this.router.events.subscribe((event) => { + if (event instanceof NavigationStart) { + this.closeDetails(); + } + }), + ); + this.subscriptions.add( + 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(); + this.staffRequest?.unsubscribe(); + this.mutationRequest?.unsubscribe(); + } + + get units(): HubUnit[] { + return this.feed.units; + } + get manageableUnits(): HubUnit[] { + return this.demo.enabled ? [] : this.units.filter((unit) => unit.can_manage); + } + get visibleAnnouncements(): UnitAnnouncement[] { + const rows = this.announcements; + return this.showAllAnnouncements ? rows : rows.slice(0, this.announcementPage); + } + get visibleSessionGroups() { + const groups = this.sessionGroups; + return this.showAllSessionDays ? groups : groups.slice(0, this.sessionDayPage); + } + get announcements(): UnitAnnouncement[] { + return this.feed.announcements.filter( + (row) => !this.selectedUnitId || row.unit_id === this.selectedUnitId, + ); + } + get sessions(): LearningSession[] { + return this.feed.sessions.filter( + (row) => !this.selectedUnitId || row.unit_id === this.selectedUnitId, + ); + } + get canManageSelected(): boolean { + return this.manageableUnits.some((unit) => unit.id === this.managedUnitId); + } + get managedTeamsConfigured(): boolean { + return this.units.find((unit) => unit.id === this.managedUnitId)?.teams_sync === 'configured'; + } + /** Read status is a student feed concern; staff managing content do not see it. */ + isUnread(row: UnitAnnouncement): boolean { + return this.readStatus.isUnread(row); + } + get unreadCount(): number { + return this.announcements.filter((row) => this.readStatus.isUnread(row)).length; + } + 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'; + } + unitCode(id: number): string { + return this.units.find((unit) => unit.id === id)?.code ?? ''; + } + kindLabel(kind: string): string { + return this.sessionKinds.find((item) => item.value === kind)?.label ?? 'Class or event'; + } + kindIcon(kind: string): string { + return this.sessionKinds.find((item) => item.value === kind)?.icon ?? 'event'; + } + kindTone(kind: string): string { + return kind === 'helphub' || kind === 'lecture' ? kind : 'class'; + } + get showFeed(): boolean { + return !this.loading && !this.loadError && !this.managing && this.units.length > 0; + } + + /** What each task status means, read from the same source the task pages use. */ + public readonly statusGuide = buildStatusGuide(); + public statusGuideOpen = false; + + public toggleStatusGuide(): void { + this.statusGuideOpen = !this.statusGuideOpen; + } + /** Upcoming sessions grouped by the local calendar day they start on. */ + get sessionGroups(): {key: string; label: string; date: string; sessions: LearningSession[]}[] { + const dayKey = (value: Date) => + `${value.getFullYear()}-${value.getMonth() + 1}-${value.getDate()}`; + const today = new Date(); + const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); + const groups: {key: string; label: string; date: string; sessions: LearningSession[]}[] = []; + for (const session of this.sessions) { + const key = dayKey(new Date(session.start_at)); + let group = groups.find((item) => item.key === key); + if (!group) { + const label = key === dayKey(today) ? 'Today' : key === dayKey(tomorrow) ? 'Tomorrow' : ''; + group = {key, label, date: session.start_at, sessions: []}; + groups.push(group); + } + 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 { + this.openEditor('announcement'); + } + startSession(): void { + this.openEditor('session'); + } + private openEditor(kind: 'announcement' | 'session'): void { + if (!this.managing) { + this.toggleManage(); + } + if (kind === 'announcement') { + this.editAnnouncement(); + } else { + this.editSession(); + } + } + timing(session: LearningSession): SessionTiming { + return sessionTiming(session, this.now); + } + get upNext(): LearningSession | null { + return upNextSession(this.sessions, this.now); + } + + reload(): void { + this.closeDetails(); + this.feedRequest?.unsubscribe(); + this.staffRequest?.unsubscribe(); + this.feed = {units: [], announcements: [], sessions: []}; + this.staffAnnouncements = []; + this.staffSessions = []; + this.editor = null; + this.deleteCandidate = null; + this.loadError = ''; + this.loading = true; + this.feedRequest = this.service.feed().subscribe({ + next: (feed) => { + this.feed = feed; + this.readStatus.sync(feed.announcements, !this.demo.enabled); + this.unavailableUnit = + !!this.selectedUnitId && !feed.units.some((unit) => unit.id === this.selectedUnitId); + this.loading = false; + 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; + }, + }); + } + + selectUnit(value: string): void { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: {unit: Number(value) || null}, + queryParamsHandling: 'merge', + }); + } + + 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 + ? // 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) { + 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(); + } + } + + joinUrl(session: LearningSession): string | null { + if (session.cancelled || (this.demo.enabled && !session.demo_hosted_join)) { + return null; + } + return safeHttpsUrl(session.join_url); + } + + openAnnouncement(announcement: UnitAnnouncement): void { + const unit = this.units.find((item) => item.id === announcement.unit_id); + if (!unit || !this.announcements.includes(announcement)) { + return; + } + this.readStatus.markRead([announcement]); + this.changes.markForCheck(); + this.showDetails({ + unitCode: unit.code, + unitName: unit.name, + demo: this.demo.enabled, + canManage: !this.demo.enabled && unit.can_manage, + announcement: Object.freeze({...announcement}), + }); + } + + openSession(session: LearningSession): void { + const unit = this.units.find((item) => item.id === session.unit_id); + if (!unit || !this.sessions.includes(session)) { + return; + } + this.showDetails({ + unitCode: unit.code, + unitName: unit.name, + demo: this.demo.enabled, + canManage: !this.demo.enabled && unit.can_manage, + session: Object.freeze({...session}), + joinUrl: this.joinUrl(session), + googleCalendarUrl: session.cancelled ? null : this.googleUrl(session, unit.code), + download: () => this.downloadSession(session), + calendarSettings: () => this.openCalendar(), + }); + } + + private showDetails(data: UnitHubDetailsData): void { + this.closeDetails(); + const ref = this.dialogs.open(UnitHubDetailsComponent, { + data, + width: '760px', + maxWidth: 'calc(100vw - 24px)', + maxHeight: '90dvh', + ariaLabelledBy: 'unit-hub-detail-title', + autoFocus: '#unit-hub-detail-title', + restoreFocus: true, + disableClose: false, + closeOnNavigation: true, + exitAnimationDuration: 0, + }); + this.detailsRef = ref; + this.subscriptions.add( + ref.afterClosed().subscribe(() => { + ref.componentInstance?.clear(); + if (this.detailsRef === ref) { + this.detailsRef = undefined; + } + }), + ); + } + + private closeDetails(): void { + this.detailsRef?.componentInstance?.clear(); + this.detailsRef?.close(); + this.detailsRef = undefined; + } + + get teamsDraft(): TeamsMeetingDraft | null { + if (!this.canManageSelected || this.editor !== 'session') { + return null; + } + const values = this.sessionForm.getRawValue(); + if (!values.title.trim() || !values.start_at || !values.end_at || !values.timezone) { + return null; + } + try { + const start_at = dateTimeToIso(values.start_at, values.timezone); + const end_at = dateTimeToIso(values.end_at, values.timezone); + if (Date.parse(end_at) <= Date.parse(start_at)) { + return null; + } + return { + title: values.title.trim(), + description: values.description, + start_at, + end_at, + timezone: values.timezone, + location: values.location, + recurrence: values.recurrence, + recurrence_until: values.recurrence_until || undefined, + cancelled: values.cancelled, + }; + } catch { + return null; + } + } + + downloadSession(session: LearningSession): void { + if (session.cancelled || !this.sessions.includes(session)) { + return; + } + const content = sessionIcs( + this.calendarSession(session), + this.unitCode(session.unit_id), + new Date(), + this.demo.enabled ? 'demo' : 'live', + ); + const url = URL.createObjectURL(new Blob([content], {type: 'text/calendar;charset=utf-8'})); + const link = document.createElement('a'); + link.href = url; + link.download = `ontrack-${this.demo.enabled ? 'demo-' : ''}session-${session.id}.ics`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + this.status = this.demo.enabled + ? 'Fictional DEMO calendar copy downloaded. This is not a real session.' + : 'Calendar copy downloaded. Subscribe to keep future changes up to date.'; + } + + googleUrl(session: LearningSession, unitCode: string): string { + return googleSessionUrl(this.calendarSession(session), unitCode); + } + + private calendarSession(session: LearningSession): LearningSession { + return this.demo.enabled + ? { + ...session, + title: `DEMO · ${session.title}`, + description: `FICTIONAL ONTRACK DEMO. Fictional schedule and sample content.${session.demo_hosted_join ? ' Fictional schedule; uses a real host-provided meeting link.' : ''}\n\n${session.description}`, + join_url: session.demo_hosted_join ? safeHttpsUrl(session.join_url) : null, + source_url: null, + location: 'Fictional demo only', + } + : session; + } + + toggleManage(): void { + if (!this.manageableUnits.length) { + return; + } + this.managing = !this.managing; + this.editor = null; + this.deleteCandidate = null; + if (this.managing) { + const unit = + this.manageableUnits.find((item) => item.id === this.selectedUnitId) ?? + this.manageableUnits[0]; + this.loadStaff(unit.id); + } + } + + loadStaff(id: number): void { + this.closeDetails(); + this.staffRequest?.unsubscribe(); + this.editor = null; + this.deleteCandidate = null; + this.managedUnitId = Number(id); + this.staffAnnouncements = []; + this.staffSessions = []; + this.staffError = ''; + this.staffLoading = false; + if (!this.canManageSelected) { + return; + } + this.staffLoading = true; + this.staffRequest = forkJoin({ + announcements: this.service.announcements(this.managedUnitId), + sessions: this.service.sessions(this.managedUnitId), + }).subscribe({ + next: (result) => { + this.staffAnnouncements = result.announcements.filter( + (row) => row.unit_id === this.managedUnitId, + ); + this.staffSessions = result.sessions.filter((row) => row.unit_id === this.managedUnitId); + this.staffLoading = false; + }, + error: () => { + this.staffLoading = false; + this.staffError = + 'We could not load the content you manage. Check your unit access or try again.'; + }, + }); + } + + editAnnouncement(row?: UnitAnnouncement): void { + if (!this.canManageSelected || this.saving || (row && this.isManagedExternally(row))) { + return; + } + this.editor = 'announcement'; + this.announcementPreview = false; + this.editingId = row?.id; + this.editingPublishedAt = row?.published_at ?? null; + this.formError = ''; + this.announcementForm.reset({ + title: row?.title ?? '', + body: row?.body ?? '', + source_url: row?.source_url ?? '', + pinned: row?.pinned ?? false, + published: !!row?.published_at, + expires_at: row?.expires_at ? dateTimeInZone(row.expires_at, this.deviceTimeZone) : '', + }); + } + + editSession(row?: LearningSession): void { + if (!this.canManageSelected || this.saving) { + return; + } + this.editor = 'session'; + this.sessionPreview = false; + this.editingId = row?.id; + this.formError = ''; + const timezone = row?.timezone ?? 'Australia/Melbourne'; + this.sessionForm.reset({ + title: row?.title ?? '', + description: row?.description ?? '', + kind: row?.kind ?? 'helphub', + start_at: row ? dateTimeInZone(row.start_at, timezone) : '', + end_at: row ? dateTimeInZone(row.end_at, timezone) : '', + timezone, + join_url: row?.join_url ?? '', + source_url: row?.source_url ?? '', + location: row?.location ?? '', + published: row?.published ?? false, + cancelled: row?.cancelled ?? false, + recurrence: row?.recurrence ?? 'none', + recurrence_until: row?.recurrence_until ?? '', + }); + } + + save(): void { + if (!this.canManageSelected || this.saving || !this.editor) { + return; + } + this.formError = ''; + try { + let request; + if (this.editor === 'announcement') { + if (this.announcementForm.invalid) { + throw new Error('Enter a title and announcement, within the field limits.'); + } + const values = this.announcementForm.getRawValue(); + request = this.service.saveAnnouncement( + this.managedUnitId, + { + title: values.title.trim(), + body: values.body.trim(), + pinned: values.pinned, + source_url: this.checkedUrl(values.source_url), + published_at: values.published + ? (this.editingPublishedAt ?? new Date().toISOString()) + : null, + expires_at: values.expires_at + ? dateTimeToIso(values.expires_at, this.deviceTimeZone) + : null, + }, + this.editingId, + ); + } else { + if (this.sessionForm.invalid) { + throw new Error('Enter a title, start time, end time and time zone.'); + } + const values = this.sessionForm.getRawValue(); + const start_at = dateTimeToIso(values.start_at, values.timezone); + const end_at = dateTimeToIso(values.end_at, values.timezone); + if (Date.parse(end_at) <= Date.parse(start_at)) { + throw new Error('End time must be after start time.'); + } + if (values.recurrence === 'weekly' && !values.recurrence_until) { + throw new Error('Choose the last date for the weekly sessions.'); + } + request = this.service.saveSession( + this.managedUnitId, + { + ...values, + title: values.title.trim(), + start_at, + end_at, + join_url: this.checkedUrl(values.join_url), + source_url: this.checkedUrl(values.source_url), + recurrence_until: values.recurrence === 'weekly' ? values.recurrence_until : null, + }, + this.editingId, + ); + } + this.saving = true; + this.mutationRequest = request.subscribe({ + next: () => { + this.saving = false; + this.status = 'Your changes have been saved.'; + this.reload(); + }, + error: () => { + this.saving = false; + this.formError = + 'Could not save. Check the dates, field lengths, HTTPS links and your unit access, then try again.'; + }, + }); + } catch (error) { + this.formError = + error instanceof RangeError + ? 'Enter a valid time zone, such as Australia/Melbourne.' + : (error as Error).message; + } + } + + confirmRemove(): void { + if (!this.canManageSelected || !this.deleteCandidate || this.saving) { + return; + } + const candidate = this.deleteCandidate; + if (candidate.kind === 'announcements') { + const row = this.staffAnnouncements.find((item) => item.id === candidate.id); + if (row && this.isManagedExternally(row)) { + return; + } + } + this.saving = true; + this.mutationRequest = this.service + .remove(this.managedUnitId, candidate.kind, candidate.id) + .subscribe({ + next: () => { + this.saving = false; + this.status = + candidate.kind === 'sessions' + ? 'Session cancelled. Calendar subscribers will receive the change when their calendar refreshes.' + : 'Announcement removed.'; + this.reload(); + }, + error: () => { + this.saving = false; + this.staffError = 'Could not apply this change. Please try again.'; + }, + }); + } + + private checkedUrl(value: string): string | null { + if (!value.trim()) { + return null; + } + const safe = safeHttpsUrl(value.trim()); + if (!safe) { + throw new Error('Use a complete HTTPS link without a username, password or spaces.'); + } + return safe; + } +} diff --git a/src/app/unit-hub/unit-hub.models.ts b/src/app/unit-hub/unit-hub.models.ts new file mode 100644 index 0000000000..44846c5e1d --- /dev/null +++ b/src/app/unit-hub/unit-hub.models.ts @@ -0,0 +1,89 @@ +export interface HubUnit { + id: number; + code: string; + name: string; + can_manage: boolean; + teams_sync?: 'configured' | 'not_configured'; +} + +export interface UnitAnnouncement { + source_provider?: 'manual' | 'microsoft_teams'; + managed_externally?: boolean; + author_name?: string | null; + id: number; + unit_id: number; + unit_code?: string; + unit_name?: string; + title: string; + body: string; + published_at: string | null; + /** Last edit time when the API supplies it. Read status falls back to published_at. */ + updated_at?: string | null; + expires_at?: string | null; + source_url?: string | null; + pinned: boolean; +} + +export type SessionKind = 'helphub' | 'lecture' | 'seminar' | 'workshop' | 'other'; + +export interface LearningSession { + /** Browser-local demo fixture only; never persisted or returned by the API. */ + demo_hosted_join?: boolean; + id: number; + unit_id: number; + unit_code?: string; + unit_name?: string; + title: string; + description: string; + kind: SessionKind; + start_at: string; + end_at: string; + timezone: string; + join_url?: string | null; + location?: string | null; + source_url?: string | null; + published: boolean; + cancelled: boolean; + recurrence: 'none' | 'weekly'; + recurrence_until?: string | null; + occurrence_id?: string; + original_start_at?: string; +} + +export interface UnitHubFeed { + announcements_truncated?: boolean; + window_start?: string; + window_end?: string; + units: HubUnit[]; + announcements: UnitAnnouncement[]; + sessions: LearningSession[]; +} + +export type AnnouncementInput = Pick< + UnitAnnouncement, + 'title' | 'body' | 'published_at' | 'expires_at' | 'source_url' | 'pinned' +>; +export type SessionInput = Pick< + LearningSession, + | 'title' + | 'description' + | 'kind' + | 'start_at' + | 'end_at' + | 'timezone' + | 'join_url' + | 'location' + | 'source_url' + | 'published' + | 'cancelled' + | 'recurrence' + | 'recurrence_until' +>; + +export const SESSION_KINDS: {value: SessionKind; label: string; icon: string}[] = [ + {value: 'helphub', label: 'HelpHub', icon: 'support_agent'}, + {value: 'lecture', label: 'Lecture', icon: 'cast_for_education'}, + {value: 'seminar', label: 'Seminar', icon: 'groups'}, + {value: 'workshop', label: 'Workshop', icon: 'construction'}, + {value: 'other', label: 'Class or event', icon: 'event'}, +]; diff --git a/src/app/unit-hub/unit-hub.service.spec.ts b/src/app/unit-hub/unit-hub.service.spec.ts new file mode 100644 index 0000000000..1603049499 --- /dev/null +++ b/src/app/unit-hub/unit-hub.service.spec.ts @@ -0,0 +1,176 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {DEMO_TOOLS_AVAILABLE, DemoModeStore} from 'src/app/demo/demo-mode.store'; +import {DemoMeetingLinksStore} from '../demo/demo-meeting-links.store'; +import {unitHubDemo} from './unit-hub-demo.fixtures'; +import {AnnouncementInput, SessionInput, UnitHubFeed} from './unit-hub.models'; +import {UnitHubService, scopeHubFeed} from './unit-hub.service'; + +const announcement: AnnouncementInput = { + title: 'Update', + body: 'Bring questions', + pinned: false, + published_at: null, +}; +const session = unitHubDemo().sessions[0] as SessionInput; + +describe('Unit Hub API and demo isolation', () => { + let service: UnitHubService; + let http: HttpTestingController; + let demo: DemoModeStore; + + beforeEach(() => { + sessionStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + {provide: DEMO_TOOLS_AVAILABLE, useValue: true}, + ], + }); + service = TestBed.inject(UnitHubService); + http = TestBed.inject(HttpTestingController); + demo = TestBed.inject(DemoModeStore); + // The demo store only switches on for a loaded demo scenario. + demo.configureScenario('all-features', 111); + }); + afterEach(() => { + http.verify(); + sessionStorage.clear(); + }); + + it('normal mode reads real content when the available demo is switched off', () => { + expect(demo.available).toBe(true); + expect(demo.enabled).toBe(false); + let result: UnitHubFeed; + service.feed().subscribe((value) => (result = value)); + http.expectOne(`${API_URL}/unit_hub`).flush(unitHubDemo()); + expect(result.units.map((unit) => unit.id)).toEqual([111]); + expect(result.announcements.every((row) => row.unit_id === 111)).toBe(true); + expect(result.sessions.every((row) => row.unit_id === 111)).toBe(true); + }); + + it('demo mode uses a synthetic enrolled-unit scope without reading the API', () => { + demo.setEnabled(true); + service.feed().subscribe((feed) => { + expect(feed.units.map((unit) => unit.code)).toEqual(['SIT111']); + expect(feed.announcements.length).toBe(2); + expect(feed.sessions.length).toBe(3); + expect(feed.announcements.some((row) => row.unit_id === 102)).toBe(false); + }); + http.expectNone(`${API_URL}/unit_hub`); + }); + + it('checks demo mode when an operation is subscribed, not when it was created', () => { + const pending = service.saveAnnouncement(111, announcement); + demo.setEnabled(true); + const errors = vi.fn(); + pending.subscribe({error: errors}); + expect(errors).toHaveBeenCalledOnce(); + http.expectNone(`${API_URL}/units/111/announcements`); + }); + + it('uses hosted links only in opted-in demo HelpHubs, without API reads or altering live rows', () => { + const link = 'https://teams.microsoft.com/meet/12345?p=demo'; + TestBed.inject(DemoMeetingLinksStore).save({helpHub: link, extraHelpHub: ''}); + demo.setEnabled(true); + service.feed().subscribe((feed) => { + expect(feed.sessions[0].join_url).toBe(link); + expect(feed.sessions[0].demo_hosted_join).toBe(true); + expect(feed.sessions.filter((row) => row.demo_hosted_join)).toHaveLength(1); + expect(feed.sessions.find((row) => row.kind === 'lecture').join_url).toBeNull(); + }); + http.expectNone(`${API_URL}/unit_hub`); + demo.setEnabled(false); + service.feed().subscribe((feed) => { + expect(feed.sessions[0].join_url).toBeNull(); + expect(feed.sessions[0].demo_hosted_join).toBe(false); + }); + http.expectOne(`${API_URL}/unit_hub`).flush(unitHubDemo()); + }); + + it('blocks every staff read and write in demo mode', () => { + demo.setEnabled(true); + const errors = vi.fn(); + [ + service.announcements(111), + service.sessions(111), + service.saveAnnouncement(111, announcement), + service.saveAnnouncement(111, announcement, 1), + service.saveSession(111, session), + service.saveSession(111, session, 1), + service.remove(111, 'sessions', 1), + service.remove(111, 'announcements', 1), + ].forEach((operation) => operation.subscribe({error: errors})); + expect(errors).toHaveBeenCalledTimes(8); + http.expectNone((request) => request.url.includes('/units/')); + }); + + it('cancels live reads and writes when demo mode changes', () => { + const next = vi.fn(); + service.feed().subscribe(next); + service.saveSession(111, session).subscribe(next); + const read = http.expectOne(`${API_URL}/unit_hub`); + const write = http.expectOne(`${API_URL}/units/111/sessions`); + demo.setEnabled(true); + expect(read.cancelled).toBe(true); + expect(write.cancelled).toBe(true); + expect(next).not.toHaveBeenCalled(); + }); + + it('matches the server wrappers, HTTP methods and unit-scoped paths', () => { + service.saveAnnouncement(111, announcement).subscribe(); + const created = http.expectOne(`${API_URL}/units/111/announcements`); + expect(created.request.method).toBe('POST'); + expect(created.request.body).toEqual({announcement}); + created.flush({id: 1}); + service.saveSession(111, session, 12).subscribe(); + const updated = http.expectOne(`${API_URL}/units/111/sessions/12`); + expect(updated.request.method).toBe('PUT'); + expect(updated.request.body).toEqual({session}); + updated.flush({id: 12}); + service.remove(111, 'sessions', 12).subscribe(); + const removed = http.expectOne(`${API_URL}/units/111/sessions/12`); + expect(removed.request.method).toBe('DELETE'); + removed.flush({success: true}); + }); + + it('cannot enable fixtures in builds without demo tools', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + {provide: DEMO_TOOLS_AVAILABLE, useValue: false}, + ], + }); + demo = TestBed.inject(DemoModeStore); + service = TestBed.inject(UnitHubService); + http = TestBed.inject(HttpTestingController); + demo.configureScenario('unit-hub-spec', 1); + demo.setEnabled(true); + expect(demo.enabled).toBe(false); + service.feed().subscribe(); + http.expectOne(`${API_URL}/unit_hub`).flush({units: [], sessions: [], announcements: []}); + }); + + it('drops unsafe joining/source links, invalid dates and cross-unit records while preserving display limits', () => { + const source = unitHubDemo(); + source.sessions[0].join_url = 'javascript:alert(1)'; + source.sessions[0].source_url = 'https://user:secret@example.com'; + source.sessions[1].start_at = 'invalid'; + const result = scopeHubFeed({ + ...source, + announcements_truncated: true, + window_end: '2026-12-01', + }); + expect(result.sessions).toHaveLength(2); + expect(result.sessions[0].join_url).toBeNull(); + expect(result.sessions[0].source_url).toBeNull(); + expect(result.announcements_truncated).toBe(true); + expect(result.window_end).toBe('2026-12-01'); + }); +}); diff --git a/src/app/unit-hub/unit-hub.service.ts b/src/app/unit-hub/unit-hub.service.ts new file mode 100644 index 0000000000..c66e56e218 --- /dev/null +++ b/src/app/unit-hub/unit-hub.service.ts @@ -0,0 +1,110 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable, defer, map, of, skip, takeUntil, throwError} from 'rxjs'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {DemoMeetingLinksStore} from 'src/app/demo/demo-meeting-links.store'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; +import {safeHttpsUrl} from './unit-hub-calendar'; +import {unitHubDemo} from './unit-hub-demo.fixtures'; +import { + AnnouncementInput, + LearningSession, + SessionInput, + UnitAnnouncement, + UnitHubFeed, +} from './unit-hub.models'; + +/** Defence in depth; the API must enforce enrolment before returning any content. */ +export function scopeHubFeed(feed: UnitHubFeed): UnitHubFeed { + const units = feed.units ?? []; + const allowed = new Set(units.map((unit) => unit.id)); + return { + announcements_truncated: feed.announcements_truncated, + window_start: feed.window_start, + window_end: feed.window_end, + units, + announcements: (feed.announcements ?? []) + .filter((row) => allowed.has(row.unit_id)) + .map((row) => ({ + ...row, + source_url: safeHttpsUrl(row.source_url), + })), + sessions: (feed.sessions ?? []) + .filter((row) => allowed.has(row.unit_id)) + .filter( + (row) => + Number.isFinite(Date.parse(row.start_at)) && + Date.parse(row.end_at) > Date.parse(row.start_at), + ) + .map((row) => ({ + ...row, + join_url: row.cancelled ? null : safeHttpsUrl(row.join_url), + source_url: safeHttpsUrl(row.source_url), + })), + }; +} + +@Injectable({providedIn: 'root'}) +export class UnitHubService { + constructor( + private http: HttpClient, + private demo: DemoModeStore, + private demoLinks: DemoMeetingLinksStore, + ) {} + + feed(): Observable { + return defer(() => + this.demo.enabled + ? of(unitHubDemo(new Date(), this.demoLinks.links)) + : this.http.get(`${API_URL}/unit_hub`), + ).pipe(map(scopeHubFeed), takeUntil(this.modeChanged())); + } + + announcements(unitId: number): Observable { + return this.live(() => + this.http.get(`${API_URL}/units/${unitId}/announcements`), + ); + } + + sessions(unitId: number): Observable { + return this.live(() => this.http.get(`${API_URL}/units/${unitId}/sessions`)); + } + + saveAnnouncement( + unitId: number, + input: AnnouncementInput, + id?: number, + ): Observable { + const url = `${API_URL}/units/${unitId}/announcements${id ? `/${id}` : ''}`; + return this.live(() => + id + ? this.http.put(url, {announcement: input}) + : this.http.post(url, {announcement: input}), + ); + } + + saveSession(unitId: number, input: SessionInput, id?: number): Observable { + const url = `${API_URL}/units/${unitId}/sessions${id ? `/${id}` : ''}`; + return this.live(() => + id + ? this.http.put(url, {session: input}) + : this.http.post(url, {session: input}), + ); + } + + remove(unitId: number, kind: 'announcements' | 'sessions', id: number): Observable { + return this.live(() => this.http.delete(`${API_URL}/units/${unitId}/${kind}/${id}`)); + } + + private modeChanged(): Observable { + return this.demo.enabled$.pipe(skip(1)); + } + + private live(request: () => Observable): Observable { + return defer(() => + this.demo.enabled + ? throwError(() => new Error('Demo content cannot change live data.')) + : request(), + ).pipe(takeUntil(this.modeChanged())); + } +}