From 96fac08742baa648df95f62515315a55bdd73436 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 19:58:36 +1000 Subject: [PATCH 1/3] feat(unit-hub): unit hub and Teams sync Brings the T2 2026 unit-hub work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit Co-authored-by: Maple Fox --- docs/unit-hub.md | 93 +++ src/app/study-essentials/README.md | 23 + .../study-essentials.component.html | 33 + .../study-essentials.component.scss | 134 ++++ .../study-essentials.component.spec.ts | 64 ++ .../study-essentials.component.ts | 15 + .../study-essentials.config.ts | 70 ++ .../teams-meeting-composer.component.html | 59 ++ .../teams-meeting-composer.component.scss | 78 ++ .../teams-meeting-composer.component.spec.ts | 146 ++++ .../teams-meeting-composer.component.ts | 86 +++ src/app/unit-hub/teams-meeting-draft.spec.ts | 166 +++++ src/app/unit-hub/teams-meeting-draft.ts | 151 ++++ src/app/unit-hub/unit-hub-demo.fixtures.ts | 92 +++ .../unit-hub/unit-hub-details.component.html | 207 +++++ .../unit-hub/unit-hub-details.component.scss | 175 +++++ .../unit-hub-details.component.spec.ts | 124 +++ .../unit-hub/unit-hub-details.component.ts | 92 +++ src/app/unit-hub/unit-hub.component.html | 704 ++++++++++++++++++ src/app/unit-hub/unit-hub.component.scss | 536 +++++++++++++ src/app/unit-hub/unit-hub.component.spec.ts | 436 +++++++++++ src/app/unit-hub/unit-hub.component.ts | 571 ++++++++++++++ src/app/unit-hub/unit-hub.models.ts | 87 +++ src/app/unit-hub/unit-hub.service.spec.ts | 172 +++++ src/app/unit-hub/unit-hub.service.ts | 110 +++ 25 files changed, 4424 insertions(+) create mode 100644 docs/unit-hub.md create mode 100644 src/app/study-essentials/README.md create mode 100644 src/app/study-essentials/study-essentials.component.html create mode 100644 src/app/study-essentials/study-essentials.component.scss create mode 100644 src/app/study-essentials/study-essentials.component.spec.ts create mode 100644 src/app/study-essentials/study-essentials.component.ts create mode 100644 src/app/study-essentials/study-essentials.config.ts create mode 100644 src/app/unit-hub/teams-meeting-composer.component.html create mode 100644 src/app/unit-hub/teams-meeting-composer.component.scss create mode 100644 src/app/unit-hub/teams-meeting-composer.component.spec.ts create mode 100644 src/app/unit-hub/teams-meeting-composer.component.ts create mode 100644 src/app/unit-hub/teams-meeting-draft.spec.ts create mode 100644 src/app/unit-hub/teams-meeting-draft.ts create mode 100644 src/app/unit-hub/unit-hub-demo.fixtures.ts create mode 100644 src/app/unit-hub/unit-hub-details.component.html create mode 100644 src/app/unit-hub/unit-hub-details.component.scss create mode 100644 src/app/unit-hub/unit-hub-details.component.spec.ts create mode 100644 src/app/unit-hub/unit-hub-details.component.ts create mode 100644 src/app/unit-hub/unit-hub.component.html create mode 100644 src/app/unit-hub/unit-hub.component.scss create mode 100644 src/app/unit-hub/unit-hub.component.spec.ts create mode 100644 src/app/unit-hub/unit-hub.component.ts create mode 100644 src/app/unit-hub/unit-hub.models.ts create mode 100644 src/app/unit-hub/unit-hub.service.spec.ts create mode 100644 src/app/unit-hub/unit-hub.service.ts diff --git a/docs/unit-hub.md b/docs/unit-hub.md new file mode 100644 index 0000000000..60f27f4b1d --- /dev/null +++ b/docs/unit-hub.md @@ -0,0 +1,93 @@ +# 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 an announcement title or **Read full announcement** to open the complete post. Click a session title or **View full details** to see its full description, dates, time zone, recurrence, source and joining/calendar choices. 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 + +In a local build with demo tools enabled, open **Demo controls**, turn **Demo mode on**, then open **Unit Hub**. The existing switch reloads the application; accounts without a remembered session may need to sign in again. + +The Unit Hub demo is 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 unrelated development quiet-data mask does not hide real Unit Hub records. 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..41790ef48f --- /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..09124cc4bf --- /dev/null +++ b/src/app/study-essentials/study-essentials.component.scss @@ -0,0 +1,134 @@ +:host { + display: block; + min-width: 0; + color: var(--ot-color-text); +} + +.study-essentials { + padding: 22px; + border: 1px solid var(--ot-color-border); + border-radius: 14px; + background: var(--ot-color-surface); +} + +.essentials-heading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px 20px; + margin-bottom: 18px; +} + +h2, +p { + margin: 0; +} + +h2 { + font-size: 1.15rem; + font-weight: 650; + line-height: 1.4; +} + +p, +.external-note { + margin-top: 4px; + font-size: 0.82rem; + line-height: 1.5; +} + +.essentials-links { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 235px), 1fr)); + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +li { + min-width: 0; +} + +a { + display: flex; + align-items: center; + gap: 12px; + height: 100%; + min-height: 80px; + box-sizing: border-box; + padding: 14px; + border: 1px solid var(--ot-color-border); + border-radius: 10px; + color: inherit; + text-decoration: none; + touch-action: manipulation; + overflow-wrap: anywhere; + + &:hover { + background: var(--ot-color-surface-raised); + border-color: var(--ot-color-link); + } + + &:focus-visible { + outline: 3px solid var(--ot-color-primary); + outline-offset: 3px; + } +} + +.essential-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 36px; + height: 36px; + border-radius: 9px; + background: var(--ot-color-surface-raised); + color: var(--ot-color-link); +} + +.essential-copy { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; + + strong { + color: var(--ot-color-link); + font-size: 0.9rem; + line-height: 1.4; + } + + > span { + font-size: 0.76rem; + line-height: 1.5; + } +} + +.external-icon { + flex: 0 0 16px; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--ot-color-link); +} + +.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: 18px 14px; + } +} 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..6c0541948a --- /dev/null +++ b/src/app/study-essentials/study-essentials.component.ts @@ -0,0 +1,15 @@ +import {ChangeDetectionStrategy, Component, inject} 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)); +} 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/teams-meeting-composer.component.html b/src/app/unit-hub/teams-meeting-composer.component.html new file mode 100644 index 0000000000..5495ba846b --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.html @@ -0,0 +1,59 @@ +
+

Make a Teams meeting

+

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

+ @if (draft) { +

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

+ + + @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..dbb7a4fcf3 --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.scss @@ -0,0 +1,78 @@ +:host { + display: block; + min-width: 0; +} + +.teams-composer { + padding: 1rem; + border: 1px solid #b9c9df; + border-radius: 0.75rem; + background: #f5f8fd; + color: #20344d; + overflow-wrap: anywhere; +} + +h3 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +p { + margin: 0.6rem 0; +} +.meeting-subject { + line-height: 1.6; +} + +.field { + display: grid; + gap: 0.35rem; + margin: 1rem 0; + font-weight: 600; +} + +input, +textarea { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 0.65rem; + border: 1px solid #8498b2; + border-radius: 0.35rem; + background: white; + color: #172b43; + font: inherit; + font-weight: 400; +} + +textarea { + resize: vertical; +} +small, +.handoff-note, +.fallback-note { + font-size: 0.875rem; + font-weight: 400; +} +.repeat-note { + font-weight: 600; +} +.draft-error { + color: #9b241a; +} +input:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 3px solid #215da6; + outline-offset: 2px; +} + +@media (max-width: 480px) { + .teams-composer { + padding: 0.8rem; + } + a, + button { + width: 100%; + } +} 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..ad6f0f918b --- /dev/null +++ b/src/app/unit-hub/teams-meeting-composer.component.ts @@ -0,0 +1,86 @@ +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 {TeamsMeetingDraft, teamsMeetingContent, teamsMeetingDraftUrl} from './teams-meeting-draft'; + +@Component({ + selector: 'f-teams-meeting-composer', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, MatButtonModule], + 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..0f44f8bfd1 --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.html @@ -0,0 +1,207 @@ +@if (content; as detail) { +
+
+

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

+

{{ title }}

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

{{ announcement.body }}

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

+ Cancelled · this session will not run. +

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

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

+
+ @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..0d955cb721 --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.scss @@ -0,0 +1,175 @@ +:host { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 90dvh; + color: var(--ot-color-text); +} +.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-text-muted); + font-size: 0.82rem; + overflow-wrap: anywhere; +} +h2[mat-dialog-title] { + margin: 0; + padding: 0; + color: inherit; + font-size: 1.4rem; + line-height: 1.4; + overflow-wrap: anywhere; +} +h2[mat-dialog-title]::before { + display: none; +} +.details-content { + min-height: 0; + flex: 1; + max-height: none; + padding: 6px 24px 24px; + color: inherit; + overflow-wrap: anywhere; +} +.detail-facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin: 16px 0 24px; +} +.detail-facts > div { + min-width: 0; +} +dt { + color: var(--ot-color-text-muted); + font-size: 0.78rem; + 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; +} +.full-content { + line-height: 1.7; + border-top: 1px solid var(--ot-color-divider); + padding-top: 16px; +} +.full-content p { + white-space: pre-wrap; + margin: 0; +} +.detail-notice, +.calendar-guidance { + background: var(--ot-color-surface-raised); + padding: 14px; + border-radius: 10px; + margin: 6px 0 18px; + font-size: 0.84rem; + line-height: 1.6; +} +.detail-notice { + display: flex; + align-items: flex-start; + gap: 12px; +} +.detail-notice mat-icon { + flex-shrink: 0; +} +.detail-notice p { + margin: 0; +} +.calendar-guidance { + margin: 24px 0 0; +} +.calendar-guidance h3 { + font-size: 0.95rem; + margin: 0 0 7px; +} +.calendar-guidance p { + margin: 0 0 8px; +} +.cancelled-notice { + color: var(--ot-color-error); + border: 1px solid currentColor; + padding: 12px; + border-radius: 8px; +} +.detail-source { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 22px; + color: var(--ot-color-link); + text-underline-offset: 3px; +} +.detail-source mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} +.details-actions { + display: flex; + flex-wrap: wrap; + flex-shrink: 0; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--ot-color-divider); + padding: 14px 24px; +} +.details-actions a { + text-decoration: none; +} +.detail-status:empty { + display: none; +} +.detail-status { + font-size: 0.84rem; + margin-top: 14px; +} +a:focus-visible, +button:focus-visible { + 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: 1fr; + gap: 14px; + } + h2[mat-dialog-title] { + font-size: 1.22rem; + } + .details-actions a, + .details-actions button { + min-height: 44px; + } +} 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..5c639f0dad --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.spec.ts @@ -0,0 +1,124 @@ +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(); + }); +}); 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..e07c277051 --- /dev/null +++ b/src/app/unit-hub/unit-hub-details.component.ts @@ -0,0 +1,92 @@ +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, 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 {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, + 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 = ''; + + constructor( + @Inject(MAT_DIALOG_DATA) data: UnitHubDetailsData, + private dialogRef: MatDialogRef, + ) { + this.content = data; + } + + get title(): string { + return this.content?.announcement?.title ?? this.content?.session?.title ?? ''; + } + + 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..38df8309a8 --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.html @@ -0,0 +1,704 @@ +
+
+
+ Home +

YOUR LEARNING, TOGETHER

+

Unit Hub

+

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

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

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

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

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

+
+ + @if (sessionForm.controls.recurrence.value === 'weekly') { + + } +
+ + + + + + @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 (feed.announcements_truncated) { +

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

+ } + @for (announcement of announcements; track announcement.id) { +
+
+ {{ unitCode(announcement.unit_id) }} + @if (isManagedExternally(announcement)) { + From Teams + } + @if (announcement.pinned) { + Pinned + } + +
+

+ +

+

{{ announcement.body }}

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

You're up to date

+

No announcements have been published for these units.

+
+ } +
+ } + @if (tab !== 'announcements') { +
+
+

HelpHubs & classes

+ {{ sessions.length }} +
+

+ Times shown in {{ deviceTimeZone }}. + @if (feed.window_end) { + Scheduled sessions through {{ feed.window_end | date: 'd MMM yyyy' }}. + } +

+ @for (session of sessions; track session.occurrence_id || session.id + session.start_at) { +
+
+ +
+
+ {{ unitCode(session.unit_id) }}{{ kindLabel(session.kind) }} +
+

+ +

+
+
+
+ +
+ {{ session.start_at | date: 'EEE, d MMM' }}{{ 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' }} + } + +
+ @if (sessionLive(session)) { + On now + } +
+ @if (session.cancelled) { +

Cancelled · this session will not run

+ } + @if (session.description) { +

{{ session.description }}

+ } + @if (session.location) { +

+ {{ session.location }} +

+ } + @if (session.recurrence === 'weekly') { +

Part of a weekly series

+ } + + @if (!session.cancelled) { +
+ @if (demo.enabled) { + @if (joinUrl(session)) { + Join hosted demo + } @else { + No host meeting linked. Add a meeting in demo controls. + } + Preview DEMO in Google Calendar + + } @else { + @if (joinUrl(session)) { + Join session + } @else { + {{ + session.location + ? 'See location details above.' + : 'Joining details will be added by your teaching team.' + }} + } + 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.

+
+ } + +
+ } +
+ + } +
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..5421e6ac95 --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.scss @@ -0,0 +1,536 @@ +:host { + display: block; + color: var(--ot-color-text); +} +.unit-hub { + max-width: 1240px; + margin: 0 auto; + padding: 28px 28px 56px; +} +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: clamp(2rem, 4vw, 2.6rem); + font-weight: 700; + letter-spacing: -0.04em; + margin: 5px 0 10px; +} +h2 { + font-size: 1.35rem; + font-weight: 650; +} +h3 { + font-size: 1.1rem; + font-weight: 650; + line-height: 1.45; +} +p { + line-height: 1.6; +} +a { + color: var(--ot-color-link); + text-decoration: underline; + text-underline-offset: 3px; +} +a, +button, +input, +select, +textarea { + &:focus-visible { + outline: 3px solid var(--ot-color-primary); + outline-offset: 3px; + } +} +button, +a, +input, +select, +textarea { + touch-action: manipulation; +} +button { + cursor: pointer; +} +button:disabled { + cursor: default; +} +.hub-heading, +.section-heading, +.hub-filters, +.card-meta, +.actions, +.heading-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.hub-heading { + justify-content: space-between; + align-items: flex-end; + margin-bottom: 26px; + gap: 24px; +} +.hub-heading > div:first-child { + max-width: 620px; +} +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 24px; +} +.eyebrow { + color: var(--ot-color-link); + letter-spacing: 0.13em; + font-size: 0.73rem; + font-weight: 750; +} +.heading-actions { + gap: 10px; +} +.notice { + padding: 18px; + border: 1px solid var(--ot-color-border); + border-radius: 12px; + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 20px; + background: var(--ot-color-surface); +} +.notice mat-icon { + flex: 0 0 24px; +} +.notice p { + margin: 5px 0; +} +.demo-notice, +.calendar-note { + background: var(--ot-color-surface-raised); +} +.error-notice, +.delete-notice { + border-color: var(--ot-color-error); +} +.status:empty { + display: none; +} +.status { + padding: 12px 0; + font-weight: 600; +} +.hub-filters { + justify-content: space-between; + margin-bottom: 28px; + align-items: flex-end; + gap: 20px; +} +.field { + display: flex; + flex-direction: column; + gap: 7px; + margin-bottom: 16px; + font-size: 0.88rem; + font-weight: 600; +} +.field input, +.field select, +.field textarea { + min-width: 0; + width: 100%; + box-sizing: border-box; + border: 1px solid var(--ot-color-border); + border-radius: 8px; + min-height: 46px; + padding: 10px 12px; + font: inherit; + font-weight: 400; + background: var(--ot-color-surface); + color: inherit; +} +.field textarea { + resize: vertical; +} +.unit-filter { + width: min(100%, 390px); + margin: 0; +} +.view-switch { + display: flex; + gap: 4px; + flex-wrap: wrap; + border-radius: 10px; + background: var(--ot-color-surface-raised); + padding: 4px; +} +.view-switch button { + background: transparent; + border: 0; + border-radius: 7px; + font: inherit; + padding: 10px 14px; + min-height: 44px; + color: inherit; +} +.view-switch button[aria-pressed='true'] { + background: var(--ot-color-surface); + font-weight: 650; + box-shadow: var(--ot-elevation-1); +} +.hub-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; + align-items: start; +} +.hub-columns > section { + min-width: 0; +} +.hub-columns.single-column { + grid-template-columns: 1fr; + max-width: 880px; +} +.section-heading { + margin-bottom: 16px; + gap: 10px; +} +.count { + font-size: 0.8rem; + border-radius: 999px; + background: var(--ot-color-surface-raised); + padding: 3px 9px; +} +.announcement-card, +.session-card, +.empty-card { + border: 1px solid var(--ot-color-border); + border-radius: 14px; + padding: 22px; + margin-bottom: 16px; + background: var(--ot-color-surface); + overflow-wrap: anywhere; +} +.announcement-card { + border-top: 3px solid var(--ot-color-primary); +} +.card-meta { + gap: 8px; + font-size: 0.76rem; +} +.unit-badge { + font-weight: 750; + color: var(--ot-color-link); + background: var(--ot-color-surface-raised); + border-radius: 5px; + padding: 4px 7px; +} +.pinned { + display: inline-flex; + align-items: center; + gap: 3px; +} +.pinned mat-icon { + font-size: 14px; + width: 14px; + height: 14px; +} +.card-meta time { + margin-left: auto; +} +.announcement-card h3 { + margin: 15px 0 10px; +} +.preserve-lines { + white-space: pre-wrap; +} +.announcement-card a, +.source-link { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 16px; + font-size: 0.85rem; +} +.announcement-card a mat-icon, +.source-link mat-icon { + width: 16px; + height: 16px; + font-size: 16px; +} +.session-identity { + display: flex; + align-items: flex-start; + gap: 13px; + margin-bottom: 18px; +} +.session-identity > div { + min-width: 0; +} +.session-identity h3 { + margin-top: 7px; +} +.session-icon { + width: 43px; + height: 43px; + background: var(--ot-color-surface-raised); + color: var(--ot-color-link); + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} +.session-time { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 0; + border-top: 1px solid var(--ot-color-border); + border-bottom: 1px solid var(--ot-color-border); + margin-bottom: 14px; +} +.session-time div { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.9rem; +} +.live-badge { + margin-left: auto; + background: var(--ot-status-complete); + color: var(--ot-status-complete-on); + font-size: 0.75rem; + font-weight: 650; + padding: 4px 8px; + border-radius: 999px; +} +.location { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.86rem; + margin-top: 12px; +} +.location mat-icon { + width: 19px; + height: 19px; + font-size: 19px; +} +.session-actions { + margin-top: 18px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.session-actions a { + text-decoration: none; +} +.is-cancelled { + border-style: dashed; +} +.cancelled-label { + font-weight: 650; + color: var(--ot-color-error); + margin-bottom: 10px; +} +.help-text { + font-size: 0.84rem; + line-height: 1.5; + margin: 10px 0; +} +.time-note { + margin: -4px 0 16px; +} +.calendar-note { + display: flex; + gap: 13px; + padding: 20px; + border-radius: 12px; + font-size: 0.86rem; +} +.calendar-note > mat-icon { + flex-shrink: 0; +} +.calendar-note p { + margin: 8px 0; +} +.hub-footer { + font-size: 0.8rem; + margin-top: 30px; +} +.empty-state { + text-align: center; + border: 1px solid var(--ot-color-border); + border-radius: 16px; + padding: 50px 24px; +} +.empty-state > mat-icon, +.empty-card > mat-icon { + color: var(--ot-color-link); + margin-bottom: 12px; +} +.empty-state p { + max-width: 590px; + margin: 12px auto 20px; +} +.empty-card p { + margin-top: 8px; +} +.manage-panel > .actions { + margin: 22px 0; +} +.editor { + max-width: 820px; + padding: 24px; + border: 1px solid var(--ot-color-border); + border-radius: 14px; + margin: 20px 0; + background: var(--ot-color-surface); +} +.editor h3 { + margin-bottom: 22px; +} +fieldset { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 18px; +} +.check-field { + display: flex; + gap: 10px; + align-items: center; + min-height: 44px; +} +.check-field input { + width: 20px; + height: 20px; + accent-color: var(--ot-color-link); +} +.management-lists { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; + margin-top: 28px; +} +.management-lists > section { + min-width: 0; +} +.managed-row { + display: flex; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; + justify-content: space-between; + padding: 16px 0; + border-bottom: 1px solid var(--ot-color-border); + overflow-wrap: anywhere; +} +.managed-row > div:first-child { + display: flex; + flex-direction: column; + gap: 6px; +} +.managed-row span { + font-size: 0.8rem; +} +@media (max-width: 850px) { + .hub-columns, + .management-lists { + grid-template-columns: 1fr; + } + .heading-actions { + width: 100%; + } +} +@media (max-width: 599px) { + .unit-hub { + padding: 20px 16px 36px; + } + .hub-heading { + margin-bottom: 20px; + } + .back-link { + margin-bottom: 18px; + } + .view-switch { + width: 100%; + } + .view-switch button { + flex: 1; + padding: 10px 7px; + font-size: 0.8rem; + } + .unit-filter { + width: 100%; + } + .announcement-card, + .session-card, + .empty-card { + padding: 18px; + } + .session-actions { + flex-direction: column; + align-items: stretch; + } + .session-actions a, + .session-actions button { + min-height: 44px; + } + .form-grid { + grid-template-columns: 1fr; + } + .editor { + padding: 18px 14px; + } + .notice { + padding: 15px; + } + .calendar-note { + padding: 16px; + } +} + +.details-title { + display: block; + width: 100%; + background: none; + border: 0; + padding: 4px 0; + color: inherit; + font: inherit; + font-weight: inherit; + text-align: left; + cursor: pointer; + overflow-wrap: anywhere; +} +.details-title:hover { + color: var(--ot-color-link); + text-decoration: underline; + text-underline-offset: 4px; +} +.details-title:focus-visible { + outline: 3px solid var(--ot-color-focus); + outline-offset: 3px; + border-radius: 3px; +} +.card-preview { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} +.details-link { + margin: 0 0 12px; +} 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..23699bda90 --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.spec.ts @@ -0,0 +1,436 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import {provideRouter} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {Subject, of, throwError} from 'rxjs'; +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'; + +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(); + 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}, + ], + }); + demo = TestBed.inject(DemoModeStore); + }); + 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 === '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'); + expect((element.querySelector('#hub-unit') as HTMLSelectElement).value).toBe('222'); + 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(); + }); +}); 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..73ba3fea62 --- /dev/null +++ b/src/app/unit-hub/unit-hub.component.ts @@ -0,0 +1,571 @@ +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, OnDestroy, OnInit} 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 {MatIconModule} from '@angular/material/icon'; +import {ActivatedRoute, NavigationStart, 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 {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, + RouterLink, + StudyEssentialsComponent, + TeamsMeetingComposerComponent, + ], + 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'; + managing = false; + managedUnitId = 0; + staffLoading = false; + staffError = ''; + staffAnnouncements: UnitAnnouncement[] = []; + staffSessions: LearningSession[] = []; + editor: 'announcement' | 'session' | null = null; + editingId?: number; + editingPublishedAt: string | null = null; + 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; + + 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, + ) { + 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(([, params]) => { + this.mutationRequest?.unsubscribe(); + this.saving = false; + this.managing = false; + this.selectedUnitId = Number(params.get('unit')) || 0; + this.reload(); + }), + ); + } + + ngOnDestroy(): void { + 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 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'; + } + 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'; + } + sessionLive(session: LearningSession): boolean { + const now = Date.now(); + return ( + !session.cancelled && Date.parse(session.start_at) <= now && Date.parse(session.end_at) > 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.unavailableUnit = + !!this.selectedUnitId && !feed.units.some((unit) => unit.id === this.selectedUnitId); + this.loading = false; + if (this.managing) { + this.loadStaff(this.managedUnitId); + } + }, + error: () => { + 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', + }); + } + + 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.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.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.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..9da262947d --- /dev/null +++ b/src/app/unit-hub/unit-hub.models.ts @@ -0,0 +1,87 @@ +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; + 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..102451c623 --- /dev/null +++ b/src/app/unit-hub/unit-hub.service.spec.ts @@ -0,0 +1,172 @@ +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); + }); + afterEach(() => { + http.verify(); + sessionStorage.clear(); + }); + + it('normal mode reads real content even when the unrelated development quiet mask is active', () => { + expect(demo.shouldMaskApiData).toBe(true); + 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.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())); + } +} From baf9445b4a73797e525bccf3872a6c3d57f7fa2c Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 22:58:49 +1000 Subject: [PATCH 2/3] feat(theme): page redesign for the unit-hub files Brings the dark mode page redesign (ontrack-features-t2-2026/doubtfire-web#280, which relands ontrack-features-t2-2026/doubtfire-web#192) to the files this PR already carries, so every file stays in exactly one PR. --- .../study-essentials.component.scss | 2 +- .../teams-meeting-composer.component.scss | 19 +++++++++++-------- src/app/unit-hub/unit-hub.component.scss | 5 +++-- src/app/unit-hub/unit-hub.component.spec.ts | 2 ++ src/app/unit-hub/unit-hub.service.spec.ts | 7 +++++-- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/app/study-essentials/study-essentials.component.scss b/src/app/study-essentials/study-essentials.component.scss index 09124cc4bf..ac79130457 100644 --- a/src/app/study-essentials/study-essentials.component.scss +++ b/src/app/study-essentials/study-essentials.component.scss @@ -72,7 +72,7 @@ a { } &:focus-visible { - outline: 3px solid var(--ot-color-primary); + outline: 3px solid var(--ot-color-focus); outline-offset: 3px; } } diff --git a/src/app/unit-hub/teams-meeting-composer.component.scss b/src/app/unit-hub/teams-meeting-composer.component.scss index dbb7a4fcf3..20988fe833 100644 --- a/src/app/unit-hub/teams-meeting-composer.component.scss +++ b/src/app/unit-hub/teams-meeting-composer.component.scss @@ -5,10 +5,10 @@ .teams-composer { padding: 1rem; - border: 1px solid #b9c9df; + border: 1px solid var(--ot-color-divider); border-radius: 0.75rem; - background: #f5f8fd; - color: #20344d; + background: color-mix(in srgb, var(--ot-color-link) 6%, var(--ot-color-surface)); + color: var(--ot-color-text); overflow-wrap: anywhere; } @@ -37,10 +37,11 @@ textarea { width: 100%; min-width: 0; padding: 0.65rem; - border: 1px solid #8498b2; + // 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: 0.35rem; - background: white; - color: #172b43; + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); font: inherit; font-weight: 400; } @@ -58,12 +59,14 @@ small, font-weight: 600; } .draft-error { - color: #9b241a; + // Pulled a fifth of the way to the text colour, which keeps it above 4.5:1 on the + // tinted box in both themes (the plain error token is 4.2:1 there in light). + color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text)); } input:focus-visible, textarea:focus-visible, a:focus-visible { - outline: 3px solid #215da6; + outline: 3px solid var(--ot-color-focus); outline-offset: 2px; } diff --git a/src/app/unit-hub/unit-hub.component.scss b/src/app/unit-hub/unit-hub.component.scss index 5421e6ac95..e2c0b04fd3 100644 --- a/src/app/unit-hub/unit-hub.component.scss +++ b/src/app/unit-hub/unit-hub.component.scss @@ -42,7 +42,7 @@ input, select, textarea { &:focus-visible { - outline: 3px solid var(--ot-color-primary); + outline: 3px solid var(--ot-color-focus); outline-offset: 3px; } } @@ -145,7 +145,8 @@ button:disabled { min-width: 0; width: 100%; box-sizing: border-box; - border: 1px solid var(--ot-color-border); + // 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: 8px; min-height: 46px; padding: 10px 12px; diff --git a/src/app/unit-hub/unit-hub.component.spec.ts b/src/app/unit-hub/unit-hub.component.spec.ts index 23699bda90..475e778a97 100644 --- a/src/app/unit-hub/unit-hub.component.spec.ts +++ b/src/app/unit-hub/unit-hub.component.spec.ts @@ -46,6 +46,8 @@ describe('Unit Hub route, forms and rendered content', () => { ], }); demo = TestBed.inject(DemoModeStore); + // This branch's demo store only switches on for a loaded demo scenario. + demo.configureScenario('unit-hub-spec', 1); }); afterEach(() => { TestBed.inject(MatDialog).closeAll(); diff --git a/src/app/unit-hub/unit-hub.service.spec.ts b/src/app/unit-hub/unit-hub.service.spec.ts index 102451c623..a34814a4f8 100644 --- a/src/app/unit-hub/unit-hub.service.spec.ts +++ b/src/app/unit-hub/unit-hub.service.spec.ts @@ -34,14 +34,16 @@ describe('Unit Hub API and demo isolation', () => { service = TestBed.inject(UnitHubService); http = TestBed.inject(HttpTestingController); demo = TestBed.inject(DemoModeStore); + // This branch's demo store only switches on for a loaded demo scenario. + demo.configureScenario('unit-hub-spec', 1); }); afterEach(() => { http.verify(); sessionStorage.clear(); }); - it('normal mode reads real content even when the unrelated development quiet mask is active', () => { - expect(demo.shouldMaskApiData).toBe(true); + it('normal mode reads real content while demo mode is off', () => { + expect(demo.enabled).toBe(false); let result: UnitHubFeed; service.feed().subscribe((value) => (result = value)); http.expectOne(`${API_URL}/unit_hub`).flush(unitHubDemo()); @@ -147,6 +149,7 @@ describe('Unit Hub API and demo isolation', () => { 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(); From 4e3fb15f05c13bb5fab5b2bccc5746c87de09971 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Mon, 28 Sep 2026 00:29:00 +1000 Subject: [PATCH 3/3] feat(unit-hub): bring in org web PR 281 for the unit-hub files Brings ontrack-features-t2-2026/doubtfire-web#281 to the files this PR already carries, so every file stays in exactly one PR. It carries the task status guide, announcement read tracking, session timing and simple markdown in the hub. Co-authored-by: Maple Fox --- docs/unit-hub.md | 10 +- .../study-essentials.component.html | 4 +- .../study-essentials.component.scss | 238 ++- .../study-essentials.component.ts | 4 +- src/app/unit-hub/_hub-card.scss | 294 ++++ .../unit-hub/announcement-read.store.spec.ts | 145 ++ src/app/unit-hub/announcement-read.store.ts | 132 ++ src/app/unit-hub/hub-markdown.spec.ts | 64 + src/app/unit-hub/hub-markdown.ts | 106 ++ src/app/unit-hub/session-timing.spec.ts | 55 + src/app/unit-hub/session-timing.ts | 60 + src/app/unit-hub/status-guide.spec.ts | 45 + src/app/unit-hub/status-guide.ts | 81 ++ .../teams-meeting-composer.component.html | 126 +- .../teams-meeting-composer.component.scss | 106 +- .../teams-meeting-composer.component.ts | 3 +- .../unit-hub/unit-hub-details.component.html | 110 +- .../unit-hub/unit-hub-details.component.scss | 147 +- .../unit-hub-details.component.spec.ts | 25 + .../unit-hub/unit-hub-details.component.ts | 40 +- src/app/unit-hub/unit-hub.component.html | 1241 +++++++++++----- src/app/unit-hub/unit-hub.component.scss | 1282 +++++++++++++---- src/app/unit-hub/unit-hub.component.spec.ts | 223 ++- src/app/unit-hub/unit-hub.component.ts | 228 ++- src/app/unit-hub/unit-hub.models.ts | 2 + src/app/unit-hub/unit-hub.service.spec.ts | 7 +- 26 files changed, 3894 insertions(+), 884 deletions(-) create mode 100644 src/app/unit-hub/_hub-card.scss create mode 100644 src/app/unit-hub/announcement-read.store.spec.ts create mode 100644 src/app/unit-hub/announcement-read.store.ts create mode 100644 src/app/unit-hub/hub-markdown.spec.ts create mode 100644 src/app/unit-hub/hub-markdown.ts create mode 100644 src/app/unit-hub/session-timing.spec.ts create mode 100644 src/app/unit-hub/session-timing.ts create mode 100644 src/app/unit-hub/status-guide.spec.ts create mode 100644 src/app/unit-hub/status-guide.ts diff --git a/docs/unit-hub.md b/docs/unit-hub.md index 60f27f4b1d..3300c17316 100644 --- a/docs/unit-hub.md +++ b/docs/unit-hub.md @@ -8,7 +8,7 @@ The normal version reads the signed-in user's authorised `/api/unit_hub` respons 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 an announcement title or **Read full announcement** to open the complete post. Click a session title or **View full details** to see its full description, dates, time zone, recurrence, source and joining/calendar choices. 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. +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 @@ -44,9 +44,11 @@ One-off Google drafts and imported files do not receive schedule changes. Subscr ## Demonstration -In a local build with demo tools enabled, open **Demo controls**, turn **Demo mode on**, then open **Unit Hub**. The existing switch reloads the application; accounts without a remembered session may need to sign in again. +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. -The Unit Hub demo is 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. +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. @@ -56,7 +58,7 @@ Google drafts and .ics downloads require an explicit user action and are labelle 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 unrelated development quiet-data mask does not hide real Unit Hub records. See the [demo guide](../src/app/demo/README.md) for local setup and removal notes. +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. diff --git a/src/app/study-essentials/study-essentials.component.html b/src/app/study-essentials/study-essentials.component.html index 41790ef48f..ffbeb172fd 100644 --- a/src/app/study-essentials/study-essentials.component.html +++ b/src/app/study-essentials/study-essentials.component.html @@ -1,8 +1,8 @@ @if (links.length) { -
+
-

Study essentials

+

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 index ac79130457..da586873e2 100644 --- a/src/app/study-essentials/study-essentials.component.scss +++ b/src/app/study-essentials/study-essentials.component.scss @@ -1,3 +1,7 @@ +@use '../unit-hub/hub-card' as hub; + +@include hub.card-theme; + :host { display: block; min-width: 0; @@ -5,10 +9,9 @@ } .study-essentials { - padding: 22px; - border: 1px solid var(--ot-color-border); - border-radius: 14px; - background: var(--ot-color-surface); + @include hub.card(page); + padding: 24px; + border-radius: var(--ot-radius-lg); } .essentials-heading { @@ -16,8 +19,8 @@ flex-wrap: wrap; align-items: baseline; justify-content: space-between; - gap: 8px 20px; - margin-bottom: 18px; + gap: 4px 24px; + margin-bottom: 16px; } h2, @@ -26,22 +29,34 @@ p { } h2 { + display: flex; + align-items: center; + gap: 8px; font-size: 1.15rem; font-weight: 650; - line-height: 1.4; + 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-fit, minmax(min(100%, 235px), 1fr)); - gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); + gap: 12px; margin: 0; padding: 0; list-style: none; @@ -52,23 +67,33 @@ li { } a { + position: relative; display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; gap: 12px; height: 100%; - min-height: 80px; + min-height: 112px; box-sizing: border-box; - padding: 14px; - border: 1px solid var(--ot-color-border); - border-radius: 10px; + 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 { - background: var(--ot-color-surface-raised); border-color: var(--ot-color-link); + box-shadow: var(--ot-elevation-1); + } + + &:hover strong { + text-decoration: underline; + text-underline-offset: 3px; } &:focus-visible { @@ -81,38 +106,50 @@ a { display: flex; align-items: center; justify-content: center; - flex: 0 0 36px; - height: 36px; - border-radius: 9px; - background: var(--ot-color-surface-raised); - color: var(--ot-color-link); + 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: 1; flex-direction: column; gap: 4px; min-width: 0; + padding-right: 20px; strong { - color: var(--ot-color-link); - font-size: 0.9rem; - line-height: 1.4; + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 650; + line-height: 1.35; } > span { - font-size: 0.76rem; - line-height: 1.5; + 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 { - flex: 0 0 16px; + position: absolute; + top: 16px; + right: 16px; width: 16px; height: 16px; font-size: 16px; - color: var(--ot-color-link); + color: var(--ot-color-text-muted); } .visually-hidden { @@ -129,6 +166,149 @@ a { @media (max-width: 599px) { .study-essentials { - padding: 18px 14px; + 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.ts b/src/app/study-essentials/study-essentials.component.ts index 6c0541948a..3de6b32ad8 100644 --- a/src/app/study-essentials/study-essentials.component.ts +++ b/src/app/study-essentials/study-essentials.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, input} from '@angular/core'; import {MatIconModule} from '@angular/material/icon'; import {STUDY_ESSENTIALS_PROFILE, studyEssentialsFor} from './study-essentials.config'; @@ -12,4 +12,6 @@ import {STUDY_ESSENTIALS_PROFILE, studyEssentialsFor} from './study-essentials.c }) 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/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 index 5495ba846b..4f3503fdd1 100644 --- a/src/app/unit-hub/teams-meeting-composer.component.html +++ b/src/app/unit-hub/teams-meeting-composer.component.html @@ -1,59 +1,81 @@
-

Make a Teams meeting

-

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

+

+ Make a Teams meeting +

+

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

@if (draft) { -

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

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

+

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

+
+ @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 index 20988fe833..6f33bb392f 100644 --- a/src/app/unit-hub/teams-meeting-composer.component.scss +++ b/src/app/unit-hub/teams-meeting-composer.component.scss @@ -1,33 +1,60 @@ +@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 { - padding: 1rem; - border: 1px solid var(--ot-color-divider); - border-radius: 0.75rem; - background: color-mix(in srgb, var(--ot-color-link) 6%, var(--ot-color-surface)); + @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; } -h3 { - margin: 0 0 0.5rem; - font-size: 1.1rem; +.composer-heading { + @include hub.icon-heading; } p { - margin: 0.6rem 0; + 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 { +.meeting-subject p:not(.group-label) { line-height: 1.6; } .field { display: grid; gap: 0.35rem; - margin: 1rem 0; + margin: 0 0 8px; + font-size: 0.85rem; font-weight: 600; } @@ -39,11 +66,16 @@ textarea { 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: 0.35rem; + 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 { @@ -52,16 +84,49 @@ textarea { small, .handoff-note, .fallback-note { - font-size: 0.875rem; + color: var(--ot-color-text-muted); + font-size: 0.85rem; font-weight: 400; } -.repeat-note { +.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 - // tinted box in both themes (the plain error token is 4.2:1 there in light). + // 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, @@ -70,12 +135,17 @@ a:focus-visible { outline-offset: 2px; } -@media (max-width: 480px) { +@media (max-width: 599px) { .teams-composer { - padding: 0.8rem; + padding: 12px; + } + .composer-actions { + flex-direction: column; + align-items: stretch; } - a, - button { + .composer-actions a, + .composer-actions button { width: 100%; + min-height: 44px; } } diff --git a/src/app/unit-hub/teams-meeting-composer.component.ts b/src/app/unit-hub/teams-meeting-composer.component.ts index ad6f0f918b..16ef1f33c7 100644 --- a/src/app/unit-hub/teams-meeting-composer.component.ts +++ b/src/app/unit-hub/teams-meeting-composer.component.ts @@ -2,12 +2,13 @@ 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], + imports: [CommonModule, ReactiveFormsModule, MatButtonModule, MatIconModule], templateUrl: './teams-meeting-composer.component.html', styleUrl: './teams-meeting-composer.component.scss', changeDetection: ChangeDetectionStrategy.Eager, diff --git a/src/app/unit-hub/unit-hub-details.component.html b/src/app/unit-hub/unit-hub-details.component.html index 0f44f8bfd1..3a915f492e 100644 --- a/src/app/unit-hub/unit-hub-details.component.html +++ b/src/app/unit-hub/unit-hub-details.component.html @@ -1,8 +1,18 @@ @if (content; as detail) { -
+

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

-

{{ title }}

+

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

+ @if (cancelled) { +

+ Cancelled: this session will not run +

+ }
} @@ -173,6 +210,19 @@

Choose how to add this session

} }

{{ status }}

+ + + @@ -193,15 +243,19 @@

Choose how to add this session

rel="noopener noreferrer" target="_blank" [href]="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 index 0d955cb721..981f0ec573 100644 --- a/src/app/unit-hub/unit-hub-details.component.scss +++ b/src/app/unit-hub/unit-hub-details.component.scss @@ -1,9 +1,16 @@ +@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; @@ -21,22 +28,30 @@ } .detail-unit { margin: 0 0 8px; - color: var(--ot-color-text-muted); - font-size: 0.82rem; + 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.4rem; - line-height: 1.4; + 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; @@ -44,18 +59,23 @@ h2[mat-dialog-title]::before { color: inherit; overflow-wrap: anywhere; } +.details-content > * { + margin: 0; +} +// the dialog is a raised surface, so every card here takes the surface token .detail-facts { + @include hub.card(raised); display: grid; - grid-template-columns: 1fr 1fr; - gap: 18px; - margin: 16px 0 24px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px 24px; + margin-top: 10px; + padding: 16px; } .detail-facts > div { min-width: 0; } dt { - color: var(--ot-color-text-muted); - font-size: 0.78rem; + @include hub.label; margin-bottom: 4px; } dd { @@ -69,82 +89,86 @@ small { font-size: 0.76rem; margin-top: 3px; } +// the body gets its own quiet card on the same inset as the facts card .full-content { + @include hub.card(raised); + padding: 16px; line-height: 1.7; - border-top: 1px solid var(--ot-color-divider); - padding-top: 16px; } -.full-content p { - white-space: pre-wrap; - margin: 0; +.content-label { + @include hub.label; + margin-bottom: 8px; } -.detail-notice, -.calendar-guidance { - background: var(--ot-color-surface-raised); - padding: 14px; - border-radius: 10px; - margin: 6px 0 18px; - font-size: 0.84rem; - line-height: 1.6; +// 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 { - display: flex; - align-items: flex-start; - gap: 12px; + @include hub.callout(raised); + p { + margin: 0; + } } -.detail-notice mat-icon { - flex-shrink: 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; + } } -.detail-notice p { - margin: 0; +.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 { - margin: 24px 0 0; + @include hub.card(raised); + padding: 16px; + font-size: 0.84rem; + line-height: 1.6; } .calendar-guidance h3 { - font-size: 0.95rem; - margin: 0 0 7px; + @include hub.icon-heading; + margin-bottom: 8px; } .calendar-guidance p { - margin: 0 0 8px; -} -.cancelled-notice { - color: var(--ot-color-error); - border: 1px solid currentColor; - padding: 12px; - border-radius: 8px; + margin: 0 0 12px; } .detail-source { - display: inline-flex; - align-items: center; - gap: 6px; - margin-top: 22px; - color: var(--ot-color-link); - text-underline-offset: 3px; -} -.detail-source mat-icon { - font-size: 18px; - width: 18px; - height: 18px; + align-self: flex-start; + text-decoration: none; } .details-actions { - display: flex; - flex-wrap: wrap; + @include hub.action-row; flex-shrink: 0; justify-content: flex-end; - gap: 8px; - border-top: 1px solid var(--ot-color-divider); padding: 14px 24px; } -.details-actions a { - text-decoration: none; -} .detail-status:empty { display: none; } .detail-status { font-size: 0.84rem; - margin-top: 14px; } a:focus-visible, button:focus-visible { @@ -162,14 +186,13 @@ button:focus-visible { padding: 12px 18px; } .detail-facts { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); gap: 14px; } h2[mat-dialog-title] { font-size: 1.22rem; } - .details-actions a, - .details-actions button { - min-height: 44px; + .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 index 5c639f0dad..a654836a21 100644 --- a/src/app/unit-hub/unit-hub-details.component.spec.ts +++ b/src/app/unit-hub/unit-hub-details.component.spec.ts @@ -121,4 +121,29 @@ describe('Unit Hub full details', () => { expect(calendarSettings).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce(); }); + it('renders a markdown body and clamps a long one behind an expand toggle', async () => { + const announcement = { + ...unitHubDemo(new Date('2026-09-14T06:00:00Z')).announcements[0], + body: '**Bold** start\n\n[Guide](https://example.test/guide)', + }; + const {component, fixture, element} = await render({announcement}); + const body = element.querySelector('#unit-hub-detail-body') as HTMLElement; + expect(body.querySelector('strong')?.textContent).toBe('Bold'); + expect(body.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer'); + expect(element.querySelector('.body-toggle')).toBeNull(); + + Object.defineProperty(body, 'scrollHeight', {configurable: true, value: 5000}); + component.measureBody(); + fixture.detectChanges(); + const toggle = element.querySelector('.body-toggle') as HTMLButtonElement; + expect(toggle.textContent).toContain('Show full announcement'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBe('unit-hub-detail-body'); + expect(body.classList).toContain('is-clamped'); + toggle.click(); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(body.classList).not.toContain('is-clamped'); + expect(toggle.textContent).toContain('Show less'); + }); }); diff --git a/src/app/unit-hub/unit-hub-details.component.ts b/src/app/unit-hub/unit-hub-details.component.ts index e07c277051..f84bc4f339 100644 --- a/src/app/unit-hub/unit-hub-details.component.ts +++ b/src/app/unit-hub/unit-hub-details.component.ts @@ -1,8 +1,19 @@ import {CommonModule} from '@angular/common'; -import {ChangeDetectionStrategy, Component, Inject} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + Injector, + ViewChild, + afterNextRender, + inject, +} from '@angular/core'; import {MatButtonModule} from '@angular/material/button'; import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; import {MatIconModule} from '@angular/material/icon'; +import {HubMarkdownPipe} from './hub-markdown'; import {TeamsMeetingComposerComponent} from './teams-meeting-composer.component'; import {safeHttpsUrl} from './unit-hub-calendar'; import {LearningSession, SESSION_KINDS, UnitAnnouncement} from './unit-hub.models'; @@ -28,6 +39,7 @@ export interface UnitHubDetailsData { MatButtonModule, MatDialogModule, MatIconModule, + HubMarkdownPipe, TeamsMeetingComposerComponent, ], templateUrl: './unit-hub-details.component.html', @@ -39,18 +51,44 @@ export class UnitHubDetailsComponent { readonly safeUrl = safeHttpsUrl; content: UnitHubDetailsData | null; status = ''; + /** A body taller than this share of the viewport starts clamped behind a toggle. */ + readonly clampShare = 0.4; + bodyCollapsible = false; + bodyExpanded = false; + @ViewChild('bodyText') private bodyText?: ElementRef; + private readonly changes = inject(ChangeDetectorRef); constructor( @Inject(MAT_DIALOG_DATA) data: UnitHubDetailsData, private dialogRef: MatDialogRef, ) { this.content = data; + afterNextRender(() => this.measureBody(), {injector: inject(Injector)}); + } + + /** Decides whether the body is long enough to clamp. Its full height is kept while clamped. */ + measureBody(): void { + const body = this.bodyText?.nativeElement; + const limit = window.innerHeight * this.clampShare; + const collapsible = !!body && body.scrollHeight > limit; + if (collapsible !== this.bodyCollapsible) { + this.bodyCollapsible = collapsible; + this.changes.detectChanges(); + } + } + + toggleBody(): void { + this.bodyExpanded = !this.bodyExpanded; } get title(): string { 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' diff --git a/src/app/unit-hub/unit-hub.component.html b/src/app/unit-hub/unit-hub.component.html index 38df8309a8..4ca8aafb2a 100644 --- a/src/app/unit-hub/unit-hub.component.html +++ b/src/app/unit-hub/unit-hub.component.html @@ -1,31 +1,119 @@
+ Home
-
- Home -

YOUR LEARNING, TOGETHER

+
+

Your learning, together

Unit Hub

-

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

+

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

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

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

- @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 (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) { -
+
campaignNew announcement
@if (editor) {
-

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

+

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

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

+ Details +

+ + +
+

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

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

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

+

+ Visibility +

+ + + +

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

+
} @else {
- -
+
+

+ Details +

+ -
+
+

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

+ - + -
-

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

-
- +

+ Visibility +

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

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

- - - - - - @if (editingId) { - - } -

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

@@ -280,13 +508,22 @@

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

} @if (formError) { - + } -
+
@@ -294,7 +531,8 @@

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

} @if (deleteCandidate) {
} + } + @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 }}

    +
  • + } +
+
+ } +
+ } +
+