From b30534ff752bea63844b98a002369daaa75fb855 Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Wed, 22 Apr 2026 16:34:24 +0200 Subject: [PATCH 01/14] feat(client): add PROMPT workspace integration with autosave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1 of the Tease ↔ PROMPT auto import/export workflow so course phases can be opened, edited, and saved directly against PROMPT without the CSV round-trip. - Boot-time entry flow supports ?coursePhaseId=... (launch from PROMPT), a new project picker (when PROMPT is reachable), and the existing CSV fallback (unchanged). - New WorkspaceStateService hydrates from GET /workspace, tracks a dirty flag, autosaves via PUT /workspace (2s debounce), and publishes the finalised draft + allocations atomically via POST /save. - Navigation bar gains a project-switcher dropdown, a save status pill (saving/unsaved/saved), a Save button with a last-saved tooltip, and a "Connect to PROMPT" hint when disconnected. - beforeunload listener warns on unsaved changes. - Export overlay keeps the existing CSV/legacy-export actions; adds a primary Save to PROMPT action. --- ...-course-phase-course-phase-id-save-post.ts | 39 +++ ...rse-phase-course-phase-id-workspace-get.ts | 37 +++ ...rse-phase-course-phase-id-workspace-put.ts | 39 +++ client/src/app/api/models/tease-workspace.ts | 38 +++ client/src/app/app.component.html | 8 +- client/src/app/app.component.ts | 159 ++++++++- .../src/app/components/components.module.ts | 10 +- .../export-overlay.component.html | 25 +- .../export-overlay.component.ts | 30 +- .../navigation-bar.component.html | 75 ++++- .../navigation-bar.component.scss | 131 ++++++++ .../navigation-bar.component.ts | 123 ++++++- .../project-picker.component.html | 44 +++ .../project-picker.component.scss | 12 + .../project-picker.component.ts | 58 ++++ .../services/prompt-connection.service.ts | 88 +++++ .../src/app/shared/services/prompt.service.ts | 30 ++ .../services/workspace-state.service.ts | 313 ++++++++++++++++++ 18 files changed, 1238 insertions(+), 21 deletions(-) create mode 100644 client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-save-post.ts create mode 100644 client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-get.ts create mode 100644 client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-put.ts create mode 100644 client/src/app/api/models/tease-workspace.ts create mode 100644 client/src/app/components/project-picker/project-picker.component.html create mode 100644 client/src/app/components/project-picker/project-picker.component.scss create mode 100644 client/src/app/components/project-picker/project-picker.component.ts create mode 100644 client/src/app/shared/services/prompt-connection.service.ts create mode 100644 client/src/app/shared/services/workspace-state.service.ts diff --git a/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-save-post.ts b/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-save-post.ts new file mode 100644 index 00000000..366ae09c --- /dev/null +++ b/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-save-post.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { TeaseSaveRequest, TeaseWorkspace } from '../../models/tease-workspace'; + +export interface TeaseCoursePhaseCoursePhaseIdSavePost$Params { + /** + * Unique identifier of the course phase + */ + coursePhaseId: string; + body?: TeaseSaveRequest; +} + +export function teaseCoursePhaseCoursePhaseIdSavePost( + http: HttpClient, + rootUrl: string, + params: TeaseCoursePhaseCoursePhaseIdSavePost$Params, + context?: HttpContext +): Observable> { + const rb = new RequestBuilder(rootUrl, teaseCoursePhaseCoursePhaseIdSavePost.PATH, 'post'); + if (params) { + rb.path('coursePhaseId', params.coursePhaseId, {}); + rb.body(params.body, 'application/json'); + } + + return http.request(rb.build({ responseType: 'json', accept: 'application/json', context })).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +teaseCoursePhaseCoursePhaseIdSavePost.PATH = '/tease/course_phase/{coursePhaseId}/save'; diff --git a/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-get.ts b/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-get.ts new file mode 100644 index 00000000..5d19ad96 --- /dev/null +++ b/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-get.ts @@ -0,0 +1,37 @@ +/* tslint:disable */ +/* eslint-disable */ +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { TeaseWorkspace } from '../../models/tease-workspace'; + +export interface TeaseCoursePhaseCoursePhaseIdWorkspaceGet$Params { + /** + * Unique identifier of the course phase + */ + coursePhaseId: string; +} + +export function teaseCoursePhaseCoursePhaseIdWorkspaceGet( + http: HttpClient, + rootUrl: string, + params: TeaseCoursePhaseCoursePhaseIdWorkspaceGet$Params, + context?: HttpContext +): Observable> { + const rb = new RequestBuilder(rootUrl, teaseCoursePhaseCoursePhaseIdWorkspaceGet.PATH, 'get'); + if (params) { + rb.path('coursePhaseId', params.coursePhaseId, {}); + } + + return http.request(rb.build({ responseType: 'json', accept: 'application/json', context })).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +teaseCoursePhaseCoursePhaseIdWorkspaceGet.PATH = '/tease/course_phase/{coursePhaseId}/workspace'; diff --git a/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-put.ts b/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-put.ts new file mode 100644 index 00000000..173da4da --- /dev/null +++ b/client/src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-put.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { TeaseWorkspace, TeaseWorkspaceUpsert } from '../../models/tease-workspace'; + +export interface TeaseCoursePhaseCoursePhaseIdWorkspacePut$Params { + /** + * Unique identifier of the course phase + */ + coursePhaseId: string; + body?: TeaseWorkspaceUpsert; +} + +export function teaseCoursePhaseCoursePhaseIdWorkspacePut( + http: HttpClient, + rootUrl: string, + params: TeaseCoursePhaseCoursePhaseIdWorkspacePut$Params, + context?: HttpContext +): Observable> { + const rb = new RequestBuilder(rootUrl, teaseCoursePhaseCoursePhaseIdWorkspacePut.PATH, 'put'); + if (params) { + rb.path('coursePhaseId', params.coursePhaseId, {}); + rb.body(params.body, 'application/json'); + } + + return http.request(rb.build({ responseType: 'json', accept: 'application/json', context })).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +teaseCoursePhaseCoursePhaseIdWorkspacePut.PATH = '/tease/course_phase/{coursePhaseId}/workspace'; diff --git a/client/src/app/api/models/tease-workspace.ts b/client/src/app/api/models/tease-workspace.ts new file mode 100644 index 00000000..7a0c16a1 --- /dev/null +++ b/client/src/app/api/models/tease-workspace.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ +import { Allocation } from './allocation'; +import { ConstraintWrapper } from '../../shared/matching/constraints/constraint'; + +/** + * Persisted Tease workspace for a course phase, stored by PROMPT in + * team_allocation.tease_workspace. + */ +export interface TeaseWorkspace { + coursePhaseId: string; + constraints: ConstraintWrapper[]; + /** [studentId, projectId] pairs */ + lockedStudents: Array<[string, string]>; + allocationsDraft: Allocation[]; + algorithmType?: 'preferenceMaxLP' | 'constraintOnly' | null; + lastSavedAt?: string | null; + lastExportedAt?: string | null; + updatedBy?: string | null; +} + +/** + * Request body for PUT /tease/course_phase/{id}/workspace. + * Excludes server-managed timestamps. + */ +export type TeaseWorkspaceUpsert = Omit< + TeaseWorkspace, + 'lastSavedAt' | 'lastExportedAt' | 'updatedBy' +>; + +/** + * Request body for POST /tease/course_phase/{id}/save — the workspace + * payload plus the finalised allocations that should be upserted into + * the allocations table as part of the same DB transaction. + */ +export interface TeaseSaveRequest extends TeaseWorkspaceUpsert { + allocations: Allocation[]; +} diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index 907f9994..6814f29c 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html @@ -1,6 +1,10 @@ -@if (dataLoaded) { +@if (mode === 'picker') { + +} @else if (dataLoaded) {
- +
→ skip picker, hydrate directly. + // 2. PROMPT reachable → show project picker. + // 3. Otherwise → existing CSV import flow unchanged. + void this.initialiseEntryFlow(); - document.documentElement.style.setProperty('--utility-height', `${256}px`); + this.fetchCourseIterations(); } ngOnDestroy(): void { @@ -123,6 +136,130 @@ export class AppComponent implements OverlayServiceHost, OnInit, OnDestroy { this.resizeService.cleanup(); } + /* -------------------------------------------------------------- */ + /* Boot-time entry flow */ + /* -------------------------------------------------------------- */ + + private async initialiseEntryFlow(): Promise { + const coursePhaseId = this.readCoursePhaseIdFromUrl(); + + // Always probe PROMPT so the header dropdown knows it can offer the + // project switcher, even on the query-param path. Fire-and-forget + // when a coursePhaseId is already in hand — we don't want to block + // the launch flow waiting for probe results. + if (coursePhaseId) { + void this.promptConnectionService.probe(); + await this.hydrateFromCoursePhaseId(coursePhaseId); + return; + } + + const connected = await this.promptConnectionService.probe(); + if (connected) { + this.mode = 'picker'; + return; + } + + // No connection → fall through to the existing CSV import UI, which + // is the default behaviour when `dataLoaded` is false. + this.mode = 'csv'; + } + + private readCoursePhaseIdFromUrl(): string | null { + if (typeof window === 'undefined' || !window.location) return null; + try { + const params = new URLSearchParams(window.location.search); + const id = params.get('coursePhaseId'); + return id && id.trim().length > 0 ? id : null; + } catch { + return null; + } + } + + /** + * Invoked both on boot with `?coursePhaseId=` and from the project + * picker. Pulls students/skills/projects/allocations from PROMPT, + * then hydrates the workspace (constraints + locks + draft + + * algorithm) via `WorkspaceStateService`. + */ + private async hydrateFromCoursePhaseId(coursePhaseId: string): Promise { + this.mode = 'loading'; + try { + const [students, projects, skills] = await Promise.all([ + this.promptService.getStudents(coursePhaseId), + this.promptService.getProjects(coursePhaseId), + this.promptService.getSkills(coursePhaseId), + ]); + + this.studentsService.deleteStudents(); + this.projectsService.deleteProjects(); + this.skillsService.deleteSkills(); + + this.studentsService.setStudents(students ?? []); + this.projectsService.setProjects(projects ?? []); + this.skillsService.setSkills(skills ?? []); + + // Fetch & apply the persisted workspace (constraints, locks, + // draft allocations, algorithm type). + await this.workspaceStateService.hydrate(coursePhaseId); + + // Mirror the selected course phase into the existing + // CourseIterationsService so the rest of the app (navigation bar, + // websocket collaboration, etc.) keeps working unchanged. + const courseIteration: CourseIteration = { + id: coursePhaseId, + semesterName: this.resolveSemesterName(coursePhaseId), + }; + this.courseIterationsService.setCourseIteration(courseIteration); + + try { + await this.collaborationService.connect(coursePhaseId); + } catch (err) { + console.warn('WebSocket collaboration connect failed; continuing', err); + } + + this.mode = 'workspace'; + } catch (error) { + if (error instanceof HttpErrorResponse) { + this.toastsService.showToast( + `Error ${error.status}: ${error.statusText}`, + 'Hydrate failed', + false + ); + } else { + this.toastsService.showToast('Could not load course phase from PROMPT.', 'Hydrate failed', false); + } + // Fall back to CSV if hydration fails, matching the plan's + // precedence rule (CSV is always the safe fallback). + this.mode = 'csv'; + } + } + + /** + * Best-effort lookup of a human readable semester name from the + * cached course-phase list. Returns undefined if the cache hasn't + * been populated yet or the id isn't present. + */ + private resolveSemesterName(coursePhaseId: string): string | undefined { + return this.promptConnectionService.findCoursePhase(coursePhaseId)?.semesterName; + } + + /** Called by ``. */ + async onCoursePhaseSelected(phase: CourseIteration): Promise { + if (!phase?.id) return; + + // Reflect the selection in the URL so reloads stay on the same + // course phase (Workflow B parity). + try { + const url = new URL(window.location.href); + url.searchParams.set('coursePhaseId', phase.id); + window.history.replaceState({}, '', url); + } catch { + // Non-fatal — continue without URL mutation. + } + + await this.hydrateFromCoursePhaseId(phase.id); + } + private handleStudentDrop(el: Element, target: Element, sibling: Element): void { if (!el || !target) return; const studentId = el.children[0].id; @@ -175,8 +312,10 @@ export class AppComponent implements OverlayServiceHost, OnInit, OnDestroy { if (!courseIteration || !this.promptService.isImportPossible()) { return; } - const courseIterations = await this.promptService.getCourseIterations(); - if (!courseIterations) { + // Share the probe's inflight/cached list instead of issuing a duplicate + // GET /tease/course-phases round-trip. + const courseIterations = await this.promptConnectionService.listCoursePhases(); + if (!courseIterations.length) { return; } diff --git a/client/src/app/components/components.module.ts b/client/src/app/components/components.module.ts index f98b6088..d454c1f3 100644 --- a/client/src/app/components/components.module.ts +++ b/client/src/app/components/components.module.ts @@ -24,6 +24,7 @@ import { SharedModule } from '../shared/shared.module'; import { ConstraintBuilderNationalityComponent } from './constraint-builder-nationality/constraint-builder-nationality.component'; import { SelectComponent } from './select/select.component'; import { ConstraintHelpComponent } from './constraint-help/constraint-help.component'; +import { ProjectPickerComponent } from './project-picker/project-picker.component'; @NgModule({ declarations: [ @@ -43,8 +44,15 @@ import { ConstraintHelpComponent } from './constraint-help/constraint-help.compo ConstraintBuilderNationalityComponent, SelectComponent, ConstraintHelpComponent, + ProjectPickerComponent, + ], + exports: [ + NavigationBarComponent, + ProjectsComponent, + UtilityComponent, + IntroCardComponent, + ProjectPickerComponent, ], - exports: [NavigationBarComponent, ProjectsComponent, UtilityComponent, IntroCardComponent], providers: [], imports: [ CommonModule, diff --git a/client/src/app/components/export-overlay/export-overlay.component.html b/client/src/app/components/export-overlay/export-overlay.component.html index 253f6862..29643d28 100644 --- a/client/src/app/components/export-overlay/export-overlay.component.html +++ b/client/src/app/components/export-overlay/export-overlay.component.html @@ -5,6 +5,24 @@

Export

+ @if (canSaveToPrompt) { +
+
Save to PROMPT
+ Persist the current constraints, locks, draft allocation, and finalised team + allocation to PROMPT in a single transaction. + +
+ } +
Team Allocation Data
This exports the current team allocation as CSV file that you can use to backup and share the current project. @@ -26,9 +44,10 @@
Team and Person Card Images
-
Prompt Export
- This exports the current team allocation to prompt - +
Prompt Export (legacy)
+ Export only the final allocations to PROMPT without persisting workspace state. + Prefer "Save to PROMPT" above. +
diff --git a/client/src/app/components/export-overlay/export-overlay.component.ts b/client/src/app/components/export-overlay/export-overlay.component.ts index d5713b6a..7b9b5865 100644 --- a/client/src/app/components/export-overlay/export-overlay.component.ts +++ b/client/src/app/components/export-overlay/export-overlay.component.ts @@ -13,6 +13,7 @@ import { AllocationData } from 'src/app/shared/models/allocation-data'; import { NgxCaptureService } from 'ngx-capture'; import { Observable, forkJoin, map } from 'rxjs'; import { CourseIterationsService } from 'src/app/shared/data/course-iteration.service'; +import { WorkspaceStateService } from 'src/app/shared/services/workspace-state.service'; @Component({ selector: 'app-export-overlay', @@ -31,6 +32,7 @@ export class ExportOverlayComponent implements OverlayComponentData { @ViewChild('projectsScreen', { static: true }) projectsScreen: ElementRef; isLoading = false; + isSavingToPrompt = false; constructor( private promptService: PromptService, @@ -39,9 +41,35 @@ export class ExportOverlayComponent implements OverlayComponentData { private projectsService: ProjectsService, private studentsService: StudentsService, private courseIterationsService: CourseIterationsService, - private captureService: NgxCaptureService + private captureService: NgxCaptureService, + private workspaceStateService: WorkspaceStateService ) {} + /** + * Primary "Save to PROMPT" action — persists both the workspace and + * the finalised allocations via `POST /tease/course_phase/{id}/save`. + * + * Only available when a PROMPT course phase is currently hydrated + * (Workflow A or B). Defaults to `false` when running in CSV-only mode. + */ + get canSaveToPrompt(): boolean { + return !!this.workspaceStateService.coursePhaseId; + } + + async saveToPrompt(): Promise { + if (!this.canSaveToPrompt) return; + this.isSavingToPrompt = true; + try { + await this.workspaceStateService.saveToPrompt(); + } finally { + this.isSavingToPrompt = false; + } + } + + /** + * Legacy export-only path. Left intact per plan §6.7 — the underlying + * POST /allocations endpoint remains callable for other tools. + */ async exportPrompt() { const allocations = this.allocationsService.getAllocations(); try { diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.html b/client/src/app/components/navigation-bar/navigation-bar.component.html index af058629..bb0eddd1 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.html +++ b/client/src/app/components/navigation-bar/navigation-bar.component.html @@ -3,7 +3,42 @@
TEASE
@if (allocationData.courseIteration) { -
{{ allocationData.courseIteration.semesterName }}
+ @if (promptConnected$ | async) { +
+ +
+
Switch project
+ @if (loadingPhases) { +
Loading…
+ } @else if (availablePhases.length === 0) { +
No other projects available.
+ } @else { + @for (phase of availablePhases; track phase.id) { + + } + } +
+
+ } @else { +
+ {{ allocationData.courseIteration.semesterName }} + Connect to PROMPT to open other projects +
+ } @if (websocketService.connection?.connected) { Distribute Projects
+ @if (workspaceActive$ | async) { +
+
+ @switch (saveStatusLabel$ | async) { + @case ('saving') { + + Saving… + } + @case ('unsaved') { + + Unsaved changes + } + @case ('saved') { + + Saved + } + } +
+ +
+ } +
+ @if (workspaceActive$ | async) { + + } @for (item of dropdownItems; track item) { + } @else if (!coursePhases.length) { +
+ No course phases are available for your account on PROMPT. +
+ } @else { +
+ @for (phase of coursePhases; track phase.id) { + + } +
+ } +
+
diff --git a/client/src/app/components/project-picker/project-picker.component.scss b/client/src/app/components/project-picker/project-picker.component.scss new file mode 100644 index 00000000..929303eb --- /dev/null +++ b/client/src/app/components/project-picker/project-picker.component.scss @@ -0,0 +1,12 @@ +.project-picker { + min-height: 100vh; +} + +.project-picker-card { + width: min(640px, 100%); +} + +.project-picker-list { + max-height: 60vh; + overflow-y: auto; +} diff --git a/client/src/app/components/project-picker/project-picker.component.ts b/client/src/app/components/project-picker/project-picker.component.ts new file mode 100644 index 00000000..8cc11610 --- /dev/null +++ b/client/src/app/components/project-picker/project-picker.component.ts @@ -0,0 +1,58 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { CourseIteration } from 'src/app/api/models'; +import { PromptConnectionService } from 'src/app/shared/services/prompt-connection.service'; +import { ToastsService } from 'src/app/shared/services/toasts.service'; + +/** + * Renders a list of PROMPT course phases (Workflow A). Emits the + * selected `coursePhaseId` so the host component can trigger hydration + * and navigate to the matchmaking route. + * + * Shown only when `PromptConnectionService.connected$` is true and the + * URL does not already carry a `coursePhaseId` query param. + */ +@Component({ + selector: 'app-project-picker', + templateUrl: './project-picker.component.html', + styleUrls: ['./project-picker.component.scss'], + standalone: false, +}) +export class ProjectPickerComponent implements OnInit { + @Output() coursePhaseSelected = new EventEmitter(); + + coursePhases: CourseIteration[] = []; + isLoading = true; + hasError = false; + + constructor( + private promptConnectionService: PromptConnectionService, + private toastsService: ToastsService + ) {} + + async ngOnInit(): Promise { + await this.loadCoursePhases(); + } + + async loadCoursePhases(): Promise { + this.isLoading = true; + this.hasError = false; + try { + this.coursePhases = await this.promptConnectionService.listCoursePhases(true); + // Empty-state UI is rendered in the template; no toast needed. + } catch { + this.hasError = true; + this.toastsService.showToast( + 'Could not load course phases from PROMPT.', + 'Project picker', + false + ); + } finally { + this.isLoading = false; + } + } + + select(phase: CourseIteration): void { + if (!phase?.id) return; + this.coursePhaseSelected.emit(phase); + } +} diff --git a/client/src/app/shared/services/prompt-connection.service.ts b/client/src/app/shared/services/prompt-connection.service.ts new file mode 100644 index 00000000..61b148e0 --- /dev/null +++ b/client/src/app/shared/services/prompt-connection.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { CourseIteration } from 'src/app/api/models'; +import { PromptService } from './prompt.service'; + +/** + * Probes PROMPT (team_allocation Go server) reachability at app init + * and exposes the connection state + a helper for listing course phases. + * + * Connection check is simply `GET /tease/course-phases` with the JWT + * interceptor; success → connected, any error → disconnected. + */ +@Injectable({ + providedIn: 'root', +}) +export class PromptConnectionService { + private readonly connectedSubject$ = new BehaviorSubject(false); + private coursePhasesCache: CourseIteration[] | null = null; + private probeInFlight: Promise | null = null; + + constructor(private promptService: PromptService) {} + + /** Observable connection state; `false` until the first successful probe. */ + get connected$(): Observable { + return this.connectedSubject$.asObservable(); + } + + /** Synchronous accessor for the current connection state. */ + isConnected(): boolean { + return this.connectedSubject$.getValue(); + } + + /** + * Probe PROMPT by attempting to list course phases. Resolves to `true` + * when the call succeeds and returns a (possibly empty) array, `false` + * otherwise. Concurrent callers share the same in-flight promise. + */ + async probe(): Promise { + if (!this.promptService.isImportPossible()) { + this.connectedSubject$.next(false); + return false; + } + if (this.probeInFlight) { + return this.probeInFlight; + } + + this.probeInFlight = (async () => { + try { + const phases = await this.promptService.getCourseIterations(); + this.coursePhasesCache = phases ?? []; + this.connectedSubject$.next(true); + return true; + } catch { + this.coursePhasesCache = null; + this.connectedSubject$.next(false); + return false; + } finally { + this.probeInFlight = null; + } + })(); + + return this.probeInFlight; + } + + /** + * Look up a course phase by id from the cache. Returns `undefined` if + * the cache is empty or the id is not present. Callers that need a + * guaranteed-fresh result should `await listCoursePhases(true)` first. + */ + findCoursePhase(coursePhaseId: string): CourseIteration | undefined { + return this.coursePhasesCache?.find(phase => phase.id === coursePhaseId); + } + + /** + * Return the list of course phases from PROMPT. Re-probes if no cache + * is available; returns `[]` when PROMPT is unreachable. + */ + async listCoursePhases(forceRefresh: boolean = false): Promise { + if (!forceRefresh && this.coursePhasesCache) { + return this.coursePhasesCache; + } + const ok = await this.probe(); + if (!ok) { + return []; + } + return this.coursePhasesCache ?? []; + } +} diff --git a/client/src/app/shared/services/prompt.service.ts b/client/src/app/shared/services/prompt.service.ts index 5e0151d0..0ae8160c 100644 --- a/client/src/app/shared/services/prompt.service.ts +++ b/client/src/app/shared/services/prompt.service.ts @@ -7,8 +7,12 @@ import { teaseCoursePhaseCourseIterationIdStudentsGet as getStudents } from '../ import { teaseCoursePhaseCourseIterationIdAllocationsGet as getAllocations } from '../../api/fn/allocations/tease-course-phase-course-iteration-id-allocations-get'; import { teaseCoursePhaseCourseIterationIdAllocationsPost as postAllocations } from 'src/app/api/fn/allocations/tease-course-phase-course-iteration-id-allocations-post'; import { teaseCoursePhasesGet as getCourseIterations } from 'src/app/api/fn/course-iterations/tease-course-phases-get'; +import { teaseCoursePhaseCoursePhaseIdWorkspaceGet as getWorkspace } from 'src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-get'; +import { teaseCoursePhaseCoursePhaseIdWorkspacePut as putWorkspace } from 'src/app/api/fn/workspace/tease-course-phase-course-phase-id-workspace-put'; +import { teaseCoursePhaseCoursePhaseIdSavePost as postSave } from 'src/app/api/fn/workspace/tease-course-phase-course-phase-id-save-post'; import { Observable, lastValueFrom } from 'rxjs'; import { Skill, Student, Project, Allocation, CourseIteration } from 'src/app/api/models'; +import { TeaseSaveRequest, TeaseWorkspace, TeaseWorkspaceUpsert } from 'src/app/api/models/tease-workspace'; import { StrictHttpResponse } from 'src/app/api/strict-http-response'; import { GLOBALS } from '../utils/constants'; @@ -53,6 +57,32 @@ export class PromptService { return this.fetchValue(getCourseIterations); } + /** + * GET /tease/course_phase/{coursePhaseId}/workspace + * Returns the persisted Tease workspace for this course phase, or an empty + * default if none exists (200 with empty arrays). + */ + async getWorkspace(coursePhaseId: string): Promise { + return lastValueFrom(this.apiService.invoke(getWorkspace, { coursePhaseId })); + } + + /** + * PUT /tease/course_phase/{coursePhaseId}/workspace + * Upsert workspace. Does NOT touch the allocations table. + */ + async putWorkspace(coursePhaseId: string, workspace: TeaseWorkspaceUpsert): Promise { + return lastValueFrom(this.apiService.invoke(putWorkspace, { coursePhaseId, body: workspace })); + } + + /** + * POST /tease/course_phase/{coursePhaseId}/save + * Atomic "save and export": upserts workspace + allocations in a single + * server-side transaction, stamps last_exported_at. + */ + async postSave(coursePhaseId: string, payload: TeaseSaveRequest): Promise { + return lastValueFrom(this.apiService.invoke(postSave, { coursePhaseId, body: payload })); + } + isImportPossible(): boolean { return localStorage.getItem(GLOBALS.LS_KEY_JWT) !== null; } diff --git a/client/src/app/shared/services/workspace-state.service.ts b/client/src/app/shared/services/workspace-state.service.ts new file mode 100644 index 00000000..6902e2c2 --- /dev/null +++ b/client/src/app/shared/services/workspace-state.service.ts @@ -0,0 +1,313 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Observable, Subject, Subscription, merge } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; +import { debounceTime, skip } from 'rxjs/operators'; + +import { PromptService } from './prompt.service'; +import { ToastsService } from './toasts.service'; +import { ConstraintsService } from '../data/constraints.service'; +import { LockedStudentsService } from '../data/locked-students.service'; +import { AllocationsService } from '../data/allocations.service'; +import { TeaseSaveRequest, TeaseWorkspace, TeaseWorkspaceUpsert } from 'src/app/api/models/tease-workspace'; + +export type AlgorithmType = 'preferenceMaxLP' | 'constraintOnly'; + +/** + * Owns the connection to a single PROMPT course phase workspace. + * + * Responsibilities: + * - Hydrate constraints / locked students / draft allocations from + * `GET /tease/course_phase/{id}/workspace`. + * - Track a `dirty` flag that drives the unsaved-changes guard. + * - Autosave (debounced) via `PUT /workspace` on any constraint or + * lock change. Autosave never writes to the allocations table. + * - Expose `saveToPrompt()` for the explicit "Save to PROMPT" button, + * which calls `POST /save` (workspace + allocations, single txn). + * + * This service is intentionally standalone from the existing + * `CourseIterationsService` so that Phase 1 can land without rewriting + * the CSV flow. + */ +@Injectable({ + providedIn: 'root', +}) +export class WorkspaceStateService implements OnDestroy { + /** Debounce window (ms) for autosave after the last edit. */ + private static readonly AUTOSAVE_DEBOUNCE_MS = 2000; + + private readonly coursePhaseIdSubject$ = new BehaviorSubject(null); + private readonly dirtySubject$ = new BehaviorSubject(false); + private readonly hydratedSubject$ = new BehaviorSubject(false); + private readonly savingSubject$ = new BehaviorSubject(false); + + private algorithmType: AlgorithmType | null = null; + private readonly lastSavedAtSubject$ = new BehaviorSubject(null); + private readonly lastExportedAtSubject$ = new BehaviorSubject(null); + + private autosaveSub: Subscription | null = null; + private autosaveTrigger$ = new Subject(); + + /** Bound reference so the listener can be removed on destroy/HMR. */ + private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => { + if (this.dirtySubject$.getValue()) { + event.preventDefault(); + // Legacy browsers require a returnValue string; modern ones + // show a generic message regardless. + event.returnValue = ''; + } + }; + + constructor( + private promptService: PromptService, + private toastsService: ToastsService, + private constraintsService: ConstraintsService, + private lockedStudentsService: LockedStudentsService, + private allocationsService: AllocationsService + ) { + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', this.beforeUnloadHandler); + } + } + + ngOnDestroy(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('beforeunload', this.beforeUnloadHandler); + } + this.stopAutosaveWatcher(); + } + + /* --- observable state --------------------------------------------- */ + + get coursePhaseId(): string | null { + return this.coursePhaseIdSubject$.getValue(); + } + + get coursePhaseId$(): Observable { + return this.coursePhaseIdSubject$.asObservable(); + } + + get dirty(): boolean { + return this.dirtySubject$.getValue(); + } + + get dirty$(): Observable { + return this.dirtySubject$.asObservable(); + } + + get hydrated$(): Observable { + return this.hydratedSubject$.asObservable(); + } + + get saving$(): Observable { + return this.savingSubject$.asObservable(); + } + + getAlgorithmType(): AlgorithmType | null { + return this.algorithmType; + } + + /** ISO timestamp of the last autosave / save to PROMPT, or null. */ + get lastSavedAt$(): Observable { + return this.lastSavedAtSubject$.asObservable(); + } + + /** ISO timestamp of the last explicit "Save to PROMPT" (export), or null. */ + get lastExportedAt$(): Observable { + return this.lastExportedAtSubject$.asObservable(); + } + + setAlgorithmType(algorithmType: AlgorithmType | null): void { + if (this.algorithmType === algorithmType) return; + this.algorithmType = algorithmType; + this.markDirty(); + } + + /* --- hydration ---------------------------------------------------- */ + + /** + * Fetch `GET /workspace` and populate the in-memory editor state. + * Empty response → blank editor, no error. Network/HTTP errors surface + * a toast but do not throw; the caller can still render the UI. + */ + async hydrate(coursePhaseId: string): Promise { + // Tear down any previous watcher + pending debounce so the replay of + // constraints/locks/allocations from the new workspace doesn't trip + // markDirty() on the stale subscription. `autosaveTrigger$` is replaced + // so a still-pending debounce from before the switch cannot fire. + this.stopAutosaveWatcher(); + this.autosaveTrigger$ = new Subject(); + + this.coursePhaseIdSubject$.next(coursePhaseId); + this.hydratedSubject$.next(false); + + let workspace: TeaseWorkspace | null = null; + try { + workspace = await this.promptService.getWorkspace(coursePhaseId); + } catch (error) { + if (error instanceof HttpErrorResponse) { + this.toastsService.showToast( + `Error ${error.status}: ${error.statusText}`, + 'Workspace load failed', + false + ); + } else { + this.toastsService.showToast('Could not load workspace', 'Workspace load failed', false); + } + } + + // Empty / missing workspace → blank editor, no error. + const constraints = workspace?.constraints ?? []; + const locks = workspace?.lockedStudents ?? []; + const draft = workspace?.allocationsDraft ?? []; + this.algorithmType = (workspace?.algorithmType as AlgorithmType | null) ?? null; + this.lastSavedAtSubject$.next(workspace?.lastSavedAt ?? null); + this.lastExportedAtSubject$.next(workspace?.lastExportedAt ?? null); + + // Populate data services without broadcasting WebSocket updates — the + // hydration is authoritative for this client, not an edit. + this.constraintsService.setConstraints(constraints, false); + this.lockedStudentsService.setLocksAsArray(locks, false); + this.allocationsService.setAllocations(draft, false); + + this.dirtySubject$.next(false); + this.hydratedSubject$.next(true); + + this.startAutosaveWatcher(); + } + + /* --- dirty flag / autosave ---------------------------------------- */ + + /** Mark the workspace as having unsaved changes and schedule autosave. */ + markDirty(): void { + if (!this.coursePhaseId) return; + if (!this.dirtySubject$.getValue()) { + this.dirtySubject$.next(true); + } + this.autosaveTrigger$.next(); + } + + /** + * Subscribe to constraint / lock changes and fire debounced autosaves. + * Safe to call more than once — subsequent calls are no-ops. + */ + private startAutosaveWatcher(): void { + if (this.autosaveSub) return; + + // skip(1): BehaviorSubjects fire their current value immediately on + // subscribe; we only want *future* edits to mark the workspace dirty. + // Include allocations$ so drag-drop team changes also mark the + // workspace dirty (not just constraints/locks). + const sources$ = merge( + this.constraintsService.constraints$.pipe(skip(1)), + this.lockedStudentsService.locks$.pipe(skip(1)), + this.allocationsService.allocations$.pipe(skip(1)) + ); + + this.autosaveSub = new Subscription(); + this.autosaveSub.add( + sources$.subscribe(() => { + this.markDirty(); + }) + ); + this.autosaveSub.add( + this.autosaveTrigger$ + .pipe(debounceTime(WorkspaceStateService.AUTOSAVE_DEBOUNCE_MS)) + .subscribe(() => { + void this.autosave(); + }) + ); + } + + private stopAutosaveWatcher(): void { + this.autosaveSub?.unsubscribe(); + this.autosaveSub = null; + } + + private buildUpsertPayload(): TeaseWorkspaceUpsert { + const coursePhaseId = this.coursePhaseId; + return { + coursePhaseId: coursePhaseId ?? '', + constraints: this.constraintsService.getConstraints(), + lockedStudents: Array.from(this.lockedStudentsService.getLocks().entries()), + allocationsDraft: this.allocationsService.getAllocations(), + algorithmType: this.algorithmType, + }; + } + + private async autosave(): Promise { + const coursePhaseId = this.coursePhaseId; + if (!coursePhaseId || !this.dirtySubject$.getValue()) return; + + this.savingSubject$.next(true); + try { + const saved = await this.promptService.putWorkspace(coursePhaseId, this.buildUpsertPayload()); + if (saved?.lastSavedAt) { + this.lastSavedAtSubject$.next(saved.lastSavedAt); + } else { + // Server returned nothing — use client-side time as a best-effort fallback + this.lastSavedAtSubject$.next(new Date().toISOString()); + } + this.dirtySubject$.next(false); + } catch (error) { + // Autosave failures should not be noisy — the user still has their + // local edits. Log and keep the dirty flag so we retry on the next + // change. + console.warn('Autosave to PROMPT failed', error); + } finally { + this.savingSubject$.next(false); + } + } + + /** + * Explicit "Save to PROMPT" — POST /save. Persists workspace + the + * finalised allocations in a single server-side transaction. Returns + * `true` on success, `false` otherwise (toast already surfaced). + */ + async saveToPrompt(): Promise { + const coursePhaseId = this.coursePhaseId; + if (!coursePhaseId) { + this.toastsService.showToast('No course phase selected', 'Save failed', false); + return false; + } + + const payload: TeaseSaveRequest = { + ...this.buildUpsertPayload(), + allocations: this.allocationsService.getAllocations(), + }; + + this.savingSubject$.next(true); + try { + const saved = await this.promptService.postSave(coursePhaseId, payload); + const now = new Date().toISOString(); + this.lastSavedAtSubject$.next(saved?.lastSavedAt ?? now); + this.lastExportedAtSubject$.next(saved?.lastExportedAt ?? now); + this.dirtySubject$.next(false); + this.toastsService.showToast('Saved to PROMPT', 'Save', true); + return true; + } catch (error) { + if (error instanceof HttpErrorResponse) { + this.toastsService.showToast( + `Error ${error.status}: ${error.statusText}`, + 'Save failed', + false + ); + } else { + this.toastsService.showToast('Unknown error', 'Save failed', false); + } + return false; + } finally { + this.savingSubject$.next(false); + } + } + + /** Clear all workspace state (e.g. when switching course phases). */ + reset(): void { + this.stopAutosaveWatcher(); + this.coursePhaseIdSubject$.next(null); + this.dirtySubject$.next(false); + this.hydratedSubject$.next(false); + this.algorithmType = null; + this.lastSavedAtSubject$.next(null); + this.lastExportedAtSubject$.next(null); + } +} From 56846aeb65698c21d6eb70136e4e1aca34f037fd Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Thu, 23 Apr 2026 15:59:15 +0200 Subject: [PATCH 02/14] refactor(client): consolidate Save Teams as the single PROMPT-publish entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the duplicate "Save to PROMPT" affordances so the flow is clearer: one explicit publish action in the header, autosave for the draft, and no duplicate buttons in the kebab / export / import overlays. Header: - Rename Save → "Save Teams"; always-enabled while a workspace is active. Clicking publishes workspace + allocations via POST /save. - Status pill rewords: "Saving Workspace..." / "Unsaved changes" / "Workspace Saved". The "Unsaved changes" pill is now clickable and forces an immediate draft save (PUT /workspace) via the new WorkspaceStateService.saveWorkspaceNow() helper. - "Workspace Saved" shows a hover tooltip with the last autosave time. - Save Teams tooltip shows just the last publish time. - All tooltip times use 24-hour format. - Kebab "Save to PROMPT" item removed. Overlays: - Export: drop "Prompt Export (legacy)" section and the now-unused exportPrompt() method (+ its PromptService / ToastsService / CourseIterationsService deps). Rename the remaining primary action heading to "Save Teams" for parity with the header. - Import: drop "Import Data from PROMPT" section. CSV import and example-data flow unchanged. Autosave behaviour is unchanged (2 s debounce on constraints / locks / allocations). --- .../export-overlay.component.html | 14 ++---- .../export-overlay.component.ts | 30 ----------- .../import-overlay.component.html | 22 -------- .../navigation-bar.component.html | 50 +++++++++++-------- .../navigation-bar.component.scss | 13 +++++ .../navigation-bar.component.ts | 50 ++++++++++++------- .../services/workspace-state.service.ts | 23 +++++++++ 7 files changed, 99 insertions(+), 103 deletions(-) diff --git a/client/src/app/components/export-overlay/export-overlay.component.html b/client/src/app/components/export-overlay/export-overlay.component.html index 29643d28..294021ea 100644 --- a/client/src/app/components/export-overlay/export-overlay.component.html +++ b/client/src/app/components/export-overlay/export-overlay.component.html @@ -7,9 +7,9 @@

Export

@if (canSaveToPrompt) {
-
Save to PROMPT
- Persist the current constraints, locks, draft allocation, and finalised team - allocation to PROMPT in a single transaction. +
Save Teams
+ Publish the current team allocation to PROMPT together with the workspace draft + (constraints + locks) in a single transaction.
} @@ -43,12 +43,6 @@
Team and Person Card Images
-
-
Prompt Export (legacy)
- Export only the final allocations to PROMPT without persisting workspace state. - Prefer "Save to PROMPT" above. - -
diff --git a/client/src/app/components/export-overlay/export-overlay.component.ts b/client/src/app/components/export-overlay/export-overlay.component.ts index 7b9b5865..fdc317af 100644 --- a/client/src/app/components/export-overlay/export-overlay.component.ts +++ b/client/src/app/components/export-overlay/export-overlay.component.ts @@ -1,9 +1,6 @@ import { Component, ElementRef, ViewChild } from '@angular/core'; import { OverlayComponentData } from '../../overlay.service'; import JSZip from 'jszip'; -import { PromptService } from 'src/app/shared/services/prompt.service'; -import { ToastsService } from 'src/app/shared/services/toasts.service'; -import { HttpErrorResponse } from '@angular/common/http'; import { AllocationsService } from 'src/app/shared/data/allocations.service'; import { saveAs } from 'file-saver'; import { Allocation } from 'src/app/api/models'; @@ -12,7 +9,6 @@ import { StudentsService } from 'src/app/shared/data/students.service'; import { AllocationData } from 'src/app/shared/models/allocation-data'; import { NgxCaptureService } from 'ngx-capture'; import { Observable, forkJoin, map } from 'rxjs'; -import { CourseIterationsService } from 'src/app/shared/data/course-iteration.service'; import { WorkspaceStateService } from 'src/app/shared/services/workspace-state.service'; @Component({ @@ -35,12 +31,9 @@ export class ExportOverlayComponent implements OverlayComponentData { isSavingToPrompt = false; constructor( - private promptService: PromptService, - private toastsService: ToastsService, private allocationsService: AllocationsService, private projectsService: ProjectsService, private studentsService: StudentsService, - private courseIterationsService: CourseIterationsService, private captureService: NgxCaptureService, private workspaceStateService: WorkspaceStateService ) {} @@ -66,29 +59,6 @@ export class ExportOverlayComponent implements OverlayComponentData { } } - /** - * Legacy export-only path. Left intact per plan §6.7 — the underlying - * POST /allocations endpoint remains callable for other tools. - */ - async exportPrompt() { - const allocations = this.allocationsService.getAllocations(); - try { - const courseIteration = this.courseIterationsService.getCourseIteration(); - if (await this.promptService.postAllocations(allocations, courseIteration?.id)) { - this.toastsService.showToast('Export successful', 'Export', true); - } else { - this.toastsService.showToast('Export failed', 'Export', false); - } - } catch (error) { - if (error instanceof HttpErrorResponse) { - console.log('Error while fetching data: ', error); - this.toastsService.showToast(`Error ${error.status}: ${error.statusText}`, 'Export failed', false); - } else { - console.log('Unknown error: ', error); - } - } - } - exportCSV() { const allocations = this.allocationsService.getAllocations(); const csvData = this.allocationsToCSV(allocations); diff --git a/client/src/app/components/import-overlay/import-overlay.component.html b/client/src/app/components/import-overlay/import-overlay.component.html index 19139440..5129ec24 100644 --- a/client/src/app/components/import-overlay/import-overlay.component.html +++ b/client/src/app/components/import-overlay/import-overlay.component.html @@ -13,28 +13,6 @@
- - @if (this.isImportPossible()) { -
Import Data from PROMPT
-
-
- - -
- -
- }
diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.html b/client/src/app/components/navigation-bar/navigation-bar.component.html index bb0eddd1..ceb72cb6 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.html +++ b/client/src/app/components/navigation-bar/navigation-bar.component.html @@ -87,38 +87,49 @@ @if (workspaceActive$ | async) {
-
- @switch (saveStatusLabel$ | async) { - @case ('saving') { + @switch (saveStatusLabel$ | async) { + @case ('saving') { +
- Saving… - } - @case ('unsaved') { + Saving Workspace... +
+ } + @case ('unsaved') { + + } + @case ('saved') { +
- Saved - } + Workspace Saved +
} -
+ }
} @@ -128,11 +139,6 @@
- @if (workspaceActive$ | async) { - - } @for (item of dropdownItems; track item) {
} + @case ('error') { + + } @case ('unsaved') { -
-
Switch project
- @if (loadingPhases) { -
Loading…
- } @else if (availablePhases.length === 0) { -
No other projects available.
- } @else { - @for (phase of availablePhases; track phase.id) { - - } - } -
- - } @else { -
- {{ allocationData.courseIteration.semesterName }} - Connect to PROMPT to open other projects -
- } +
+ {{ allocationData.courseIteration.semesterName }} +
@if (websocketService.connection?.connected) { -
Save Teams
+
Save Allocations
} diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.ts b/client/src/app/components/navigation-bar/navigation-bar.component.ts index 74f31fb9..be6a3058 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.ts +++ b/client/src/app/components/navigation-bar/navigation-bar.component.ts @@ -119,7 +119,7 @@ export class NavigationBarComponent implements OnInit, OnChanges { map(lastExportedAt => lastExportedAt ? `Last saved to PROMPT: ${this.formatTimestamp(lastExportedAt)}` - : 'Click to save teams to PROMPT' + : 'Click to save allocations to PROMPT' ) ); From 8962b92b4de6afa29511f94d91fb55d52fa09f25 Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Fri, 24 Apr 2026 13:29:02 +0200 Subject: [PATCH 09/14] fix(client): harden saveToPrompt + lowercase currentcolor keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit findings — both verified, both fixed. workspace-state.service.ts: - saveToPrompt() previously snapshotted coursePhaseId before the await but then blanket-stamped lastSavedAtSubject$ / lastExportedAtSubject$ / dirtySubject$=false on resolve. Two race holes: * If the user switched workspaces while POST /save was in flight, the response stamped state on the new (wrong) workspace. * If the user kept editing during the in-flight save, dirty was cleared even though the new edits weren't in the payload. Mirrored runPutWorkspace's pattern: capture snapshotCoursePhaseId + snapshotEditCounter, abort with autosaveTrigger$.next() if a save is already in flight (so two POSTs can't race and flicker the spinner), and only mutate the timestamp / dirty subjects when both snapshots still match the current state. Edits that arrived during the save keep the dirty flag set and re-arm autosave. - Same workspace-switch guard added to runPutWorkspace itself; the PUT path had the identical hazard for autosave / saveWorkspaceNow. - Explicit save now also resets the failure-streak counter on success so a manual recovery clears the "Save failed" pill state. navigation-bar.component.scss: - Replaced `currentColor` with the lowercase `currentcolor` keyword in the .save-status-dot rule (background + box-shadow) to satisfy Stylelint's value-keyword-case rule. --- .../navigation-bar.component.scss | 4 +- .../services/workspace-state.service.ts | 67 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.scss b/client/src/app/components/navigation-bar/navigation-bar.component.scss index 0dfd9bc3..e9f9e18c 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.scss +++ b/client/src/app/components/navigation-bar/navigation-bar.component.scss @@ -123,8 +123,8 @@ width: 0.55rem; height: 0.55rem; border-radius: 50%; - background: currentColor; - box-shadow: 0 0 0 0 currentColor; + background: currentcolor; + box-shadow: 0 0 0 0 currentcolor; } .save-status-dot--saving { diff --git a/client/src/app/shared/services/workspace-state.service.ts b/client/src/app/shared/services/workspace-state.service.ts index 9baa4383..c4e9cb42 100644 --- a/client/src/app/shared/services/workspace-state.service.ts +++ b/client/src/app/shared/services/workspace-state.service.ts @@ -316,15 +316,23 @@ export class WorkspaceStateService implements OnDestroy { * concurrent invocations. */ private async runPutWorkspace(errorLogMessage: string): Promise { - const coursePhaseId = this.coursePhaseId; - if (!coursePhaseId) return false; + const snapshotCoursePhaseId = this.coursePhaseId; + if (!snapshotCoursePhaseId) return false; const snapshotEdits = this.editCounter; const payload = this.buildUpsertPayload(); this.savingSubject$.next(true); try { - const saved = await this.promptService.putWorkspace(coursePhaseId, payload); + const saved = await this.promptService.putWorkspace(snapshotCoursePhaseId, payload); + + // Bail out of UI mutations if the user has switched workspaces + // mid-flight — the response no longer corresponds to the + // currently-displayed phase. + if (this.coursePhaseId !== snapshotCoursePhaseId) { + return true; + } + this.lastSavedAtSubject$.next(saved?.lastSavedAt ?? new Date().toISOString()); if (this.editCounter === snapshotEdits) { @@ -390,16 +398,30 @@ export class WorkspaceStateService implements OnDestroy { /** * Explicit "Save to PROMPT" — POST /save. Persists workspace + the - * finalised allocations in a single server-side transaction. Returns - * `true` on success, `false` otherwise (toast already surfaced). + * finalised allocations in a single server-side transaction. + * + * Mirrors the snapshot-and-compare + in-flight guard used by + * runPutWorkspace so the explicit-publish path can't: + * - clobber state for a workspace the user has switched away from, + * - swallow edits that arrive during the in-flight POST, + * - or run two POSTs in parallel and flicker savingSubject$. + * + * Returns `true` on success, `false` otherwise (toast already surfaced). */ async saveToPrompt(): Promise { - const coursePhaseId = this.coursePhaseId; - if (!coursePhaseId) { + const snapshotCoursePhaseId = this.coursePhaseId; + if (!snapshotCoursePhaseId) { this.toastsService.showToast('No course phase selected', 'Save failed', false); return false; } + if (this.savingSubject$.getValue()) { + // Another save is already running. Re-arm autosave so the latest + // edits eventually get through, but don't fire a second POST. + this.autosaveTrigger$.next(); + return false; + } + const snapshotEdits = this.editCounter; const payload: TeaseSaveRequest = { ...this.buildUpsertPayload(), allocations: this.allocationsService.getAllocations(), @@ -407,11 +429,38 @@ export class WorkspaceStateService implements OnDestroy { this.savingSubject$.next(true); try { - const saved = await this.promptService.postSave(coursePhaseId, payload); + const saved = await this.promptService.postSave(snapshotCoursePhaseId, payload); + + // Bail out of state mutations if the user has switched workspaces + // while we were waiting on the server — the response is no longer + // about the currently-displayed phase. + if (this.coursePhaseId !== snapshotCoursePhaseId) { + return true; + } + const now = new Date().toISOString(); this.lastSavedAtSubject$.next(saved?.lastSavedAt ?? now); this.lastExportedAtSubject$.next(saved?.lastExportedAt ?? now); - this.dirtySubject$.next(false); + + if (this.editCounter === snapshotEdits) { + // No edits arrived during the POST — the workspace is truly clean. + this.dirtySubject$.next(false); + } else { + // User kept editing while we were saving — those edits weren't + // in the payload. Keep dirty=true and re-arm autosave so the + // newer state is persisted on the next debounce. + this.autosaveTrigger$.next(); + } + + // Treat an explicit save as a recovery signal for the failure-streak + // counter, same as runPutWorkspace does. + if (this.saveFailedSubject$.getValue()) { + this.toastsService.showToast('Workspace is saving again.', 'Save recovered', true); + } + this.consecutiveFailures = 0; + this.saveFailedSubject$.next(false); + this.clearRetryTimer(); + this.toastsService.showToast('Saved to PROMPT', 'Save', true); return true; } catch (error) { From f8da024a075a69ead558b15928cbb475b435cae4 Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Fri, 24 Apr 2026 01:06:36 +0200 Subject: [PATCH 10/14] fix(client): close silent-broadcast holes in distribute collaboration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distribute-with-constraints could leave peers with stale or missing state. Three concrete causes; all fixed: WebsocketService: - `send()` now returns a boolean instead of being fire-and-forget. On a closed STOMP socket it returns false AND flips a new `connected$` BehaviorSubject to false so observers can react. Wrapped in try/catch so a thrown stomp error is also surfaced as disconnect. - `Stomp.client(...)` is configured with `reconnect_delay = 5000` so the underlying socket retries automatically after drops. - New `connected$ : Observable` and synchronous `isConnected` accessor for reactive UI bindings. - `connect()` listens to the underlying socket's close/disconnect hooks and updates the connection state subject. CollaborationService: - After the initial subscribe, watches `connected$` for transitions: - true → false: surfaces a "Lost connection — edits may not sync" toast so users notice instead of seeing a green check while actually offline. - false → true: rebinds the topic subscriptions and re-runs the initial state push for the active course iteration. The legacy CompatClient does not preserve subscriptions across reconnects, so we have to re-subscribe explicitly. - Tracks `activeCourseIterationId` for the rebind path; clears it on explicit disconnect. ConstraintSummaryComponent.distributeTeams: - Re-broadcasts the constraint and lock state used by the solve before pushing the resulting allocation. This makes the distribute payload effectively atomic at the wire level — peers always receive constraints + locks no later than the allocation they produced, even if an earlier broadcast was silently dropped during a brief WS hiccup or if STOMP delivers across topics out of order. NavigationBarComponent template: - Collaboration indicator now binds reactively to `websocketService.connected$` instead of polling `connection?.connected`, so reconnect/disconnect events update the UI without requiring user interaction. --- .../constraint-summary.component.ts | 13 ++- .../navigation-bar.component.html | 2 +- .../app/shared/network/websocket.service.ts | 86 ++++++++++++++++--- .../shared/services/collaboration.service.ts | 57 +++++++++++- 4 files changed, 143 insertions(+), 15 deletions(-) diff --git a/client/src/app/components/constraint-summary-view/constraint-summary.component.ts b/client/src/app/components/constraint-summary-view/constraint-summary.component.ts index 7a7c1753..1d208242 100644 --- a/client/src/app/components/constraint-summary-view/constraint-summary.component.ts +++ b/client/src/app/components/constraint-summary-view/constraint-summary.component.ts @@ -113,9 +113,18 @@ export class ConstraintSummaryComponent implements OverlayComponentData, OnInit, ); const allocations = await this.matchingService.getAllocations(constraints); if (allocations) { - // PR note: the LP solver callback can complete outside Angular change detection. - // Re-enter the zone so the freshly computed allocations render immediately after one click. + // The LP solver callback can complete outside Angular change detection. + // Re-enter the zone so the freshly computed allocations render immediately + // after one click. Inside the zone we also re-broadcast the constraint + + // lock state used by this solve BEFORE pushing the new allocation, so + // peers can never see allocations without the producing inputs — cheap + // insurance against earlier broadcasts having dropped silently (e.g. a + // brief WS hiccup) and against cross-topic ordering surprises at the + // receiver. this.ngZone.run(() => { + this.constraintsService.setConstraints(this.constraintsService.getConstraints()); + this.lockedStudentsService.setLocks(this.lockedStudentsService.getLocks()); + this.allocationsService.setAllocations( this.studentSortService.sortStudentsInAllocations(this.studentsService.getStudents(), allocations) ); diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.html b/client/src/app/components/navigation-bar/navigation-bar.component.html index b60e848d..79b7efa1 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.html +++ b/client/src/app/components/navigation-bar/navigation-bar.component.html @@ -7,7 +7,7 @@ {{ allocationData.courseIteration.semesterName }} - @if (websocketService.connection?.connected) { + @if (websocketService.connected$ | async) { (false); - constructor() { } + /** Observable connection state for UI bindings (e.g. nav-bar indicator). */ + get connected$(): Observable { + return this.connectedSubject$.asObservable(); + } + + /** Synchronous accessor for the current connection state. */ + get isConnected(): boolean { + return this.connectedSubject$.getValue(); + } private async connect(): Promise { - return new Promise((resolve, reject) => { + return new Promise(resolve => { if (this.connection?.connected) { + this.connectedSubject$.next(true); resolve(true); return; } - this.connection = Stomp.client( + const client = Stomp.client( `${this.secure ? 'wss' : 'ws'}://${this.url}/ws?token=${localStorage.getItem(GLOBALS.LS_KEY_JWT)}` ); + // Auto-reconnect every 5 s if the underlying socket drops. Subscriptions + // do not auto-restore — collaboration callers should listen on + // `connected$` and re-subscribe on transitions false → true. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).reconnect_delay = RECONNECT_DELAY_MS; + // Quiet the noisy default debug logger. + client.debug = () => { + /* no-op */ + }; + this.connection = client; + + const onClose = () => { + this.connectedSubject$.next(false); + }; + // Both legacy and new APIs expose this hook with slightly different + // names — set both defensively. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).onWebSocketClose = onClose; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).onDisconnect = onClose; try { - this.connection.connect({}, () => { - resolve(true); - }); - } catch (error) { - reject(false); + this.connection.connect( + {}, + () => { + this.connectedSubject$.next(true); + resolve(true); + }, + () => { + // Connection error callback (legacy stomp.js API). + this.connectedSubject$.next(false); + resolve(false); + } + ); + } catch { + this.connectedSubject$.next(false); + resolve(false); } }); } - async send(courseIterationId: string, path: string, text?: string): Promise { + /** + * Send a STOMP message to `/app/course-iteration/{id}/{path}`. + * + * @returns true when the frame was handed to the STOMP client, false when + * the connection is not open. Callers that care about delivery (e.g. + * constraint / allocation broadcasts) should branch on this and surface + * an explicit "not synced" warning instead of treating the call as + * fire-and-forget. + */ + send(courseIterationId: string, path: string, text?: string): boolean { if (!this.connection?.connected) { - return; + this.connectedSubject$.next(false); + return false; + } + try { + this.connection.send(`/app/course-iteration/${courseIterationId}/${path}`, {}, text); + return true; + } catch { + // Mark disconnected so the auto-reconnect path can take over. + this.connectedSubject$.next(false); + return false; } - this.connection.send(`/app/course-iteration/${courseIterationId}/${path}`, {}, text); } async subscribe( diff --git a/client/src/app/shared/services/collaboration.service.ts b/client/src/app/shared/services/collaboration.service.ts index 5f75de7a..2fe0f6ac 100644 --- a/client/src/app/shared/services/collaboration.service.ts +++ b/client/src/app/shared/services/collaboration.service.ts @@ -8,6 +8,7 @@ import { StompSubscription } from '@stomp/stompjs'; import { ConfirmationOverlayComponent } from 'src/app/components/confirmation-overlay/confirmation-overlay.component'; import { GLOBALS } from '../utils/constants'; import { ToastsService } from './toasts.service'; +import { Subscription, distinctUntilChanged, pairwise, startWith } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -15,6 +16,10 @@ import { ToastsService } from './toasts.service'; export class CollaborationService implements OnDestroy { private discoverySubscription?: StompSubscription; private subscriptions: StompSubscription[] = []; + /** Tracks the last known connection state so we can warn on transitions. */ + private connectionWatchSub: Subscription | null = null; + /** Course iteration we last subscribed to — needed to re-subscribe on reconnect. */ + private activeCourseIterationId: string | null = null; constructor( private websocketService: WebsocketService, @@ -30,6 +35,7 @@ export class CollaborationService implements OnDestroy { subscription.unsubscribe(); } this.discoverySubscription?.unsubscribe(); + this.connectionWatchSub?.unsubscribe(); } private async discover(courseIterationId: string): Promise { @@ -58,12 +64,58 @@ export class CollaborationService implements OnDestroy { } private async subscribe(courseIterationId: string): Promise { + this.activeCourseIterationId = courseIterationId; await this.subscribeToAllocations(courseIterationId); await this.subscribeToLockedStudents(courseIterationId); await this.subscribeToConstraints(courseIterationId); + this.startConnectionWatch(); this.toatsService.showToast('Connected to Collaboration', 'Success', true); } + /** + * Watch the underlying STOMP connection and react to lifecycle changes. + * On a true → false transition we warn the user (silent send drops are + * the worst-case for collaboration UX). On false → true (auto-reconnect + * succeeded), we re-establish topic subscriptions and re-broadcast our + * current state so peers stay in sync. + */ + private startConnectionWatch(): void { + if (this.connectionWatchSub) return; + this.connectionWatchSub = this.websocketService.connected$ + .pipe(distinctUntilChanged(), startWith(true), pairwise()) + .subscribe(([prev, curr]) => { + if (prev && !curr) { + this.toatsService.showToast( + 'Lost connection to collaboration. Edits may not sync until reconnected.', + 'Collaboration offline', + false + ); + } else if (!prev && curr && this.activeCourseIterationId) { + // Auto-reconnect succeeded — rebind the subscriptions because the + // STOMP CompatClient does not preserve them across reconnects. + void this.rebindAfterReconnect(this.activeCourseIterationId); + } + }); + } + + /** Rebuild topic subscriptions and push our current state after a reconnect. */ + private async rebindAfterReconnect(courseIterationId: string): Promise { + for (const sub of this.subscriptions) sub.unsubscribe(); + this.subscriptions = []; + try { + await this.subscribeToAllocations(courseIterationId); + await this.subscribeToLockedStudents(courseIterationId); + await this.subscribeToConstraints(courseIterationId); + this.toatsService.showToast('Reconnected to collaboration.', 'Collaboration', true); + } catch { + this.toatsService.showToast( + 'Could not re-subscribe after reconnect. Please reconnect manually.', + 'Collaboration', + false + ); + } + } + async connect(courseIterationId: string): Promise { if (!courseIterationId) { return; @@ -115,8 +167,11 @@ export class CollaborationService implements OnDestroy { subscription.unsubscribe(); } this.subscriptions = []; + this.connectionWatchSub?.unsubscribe(); + this.connectionWatchSub = null; + this.activeCourseIterationId = null; - this.websocketService.connection.disconnect(); + this.websocketService.connection?.disconnect(); } private async subscribeToAllocations(courseIterationId: string): Promise { From 7cc661f173e4ccfa84d05032e3fc858b473b9045 Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Fri, 24 Apr 2026 14:21:24 +0200 Subject: [PATCH 11/14] fix(client): route reconnect rebind through discover + diff overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit findings on the collaboration distribute-sync branch: 1. `rebindAfterReconnect` was calling `subscribeToAllocations / Locked Students / Constraints` directly. Each of those methods broadcasts the FULL local snapshot to the server BEFORE subscribing. So if a peer made newer edits while we were offline, our auto-reconnect would silently overwrite their state on the server with our stale local state — exactly the data-loss class of bug this PR set out to fix, just shifted to the reconnect path. Fix: rebind delegates to the existing `connect()` flow, which runs the discovery handshake and shows the diff overlay if peer state differs. Same protection cold connect already has. 2. `startConnectionWatch` used `.startWith(true).pairwise()` which assumes the previous state was `true`. This produced a spurious "Lost connection" toast on cold start (true → false transition from the synthetic seed) and could mis-classify the very first real connection as a reconnect. Fix: anchor on `websocketService.isConnected` synchronously, track the previous value manually, and only fire on genuine transitions (`prev !== undefined && prev !== curr`). Both changes preserve the "latter changes win" property the relay's last-payload-per-topic semantics already provide, and stop the reconnect path from violating it. --- .../shared/services/collaboration.service.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/client/src/app/shared/services/collaboration.service.ts b/client/src/app/shared/services/collaboration.service.ts index 2fe0f6ac..a7c869b0 100644 --- a/client/src/app/shared/services/collaboration.service.ts +++ b/client/src/app/shared/services/collaboration.service.ts @@ -8,7 +8,7 @@ import { StompSubscription } from '@stomp/stompjs'; import { ConfirmationOverlayComponent } from 'src/app/components/confirmation-overlay/confirmation-overlay.component'; import { GLOBALS } from '../utils/constants'; import { ToastsService } from './toasts.service'; -import { Subscription, distinctUntilChanged, pairwise, startWith } from 'rxjs'; +import { Subscription, distinctUntilChanged } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -20,6 +20,8 @@ export class CollaborationService implements OnDestroy { private connectionWatchSub: Subscription | null = null; /** Course iteration we last subscribed to — needed to re-subscribe on reconnect. */ private activeCourseIterationId: string | null = null; + /** Previous value seen by the connection watcher; undefined until the first emission. */ + private lastSeenConnected: boolean | undefined = undefined; constructor( private websocketService: WebsocketService, @@ -81,9 +83,17 @@ export class CollaborationService implements OnDestroy { */ private startConnectionWatch(): void { if (this.connectionWatchSub) return; + // Anchor on the actual current value of the BehaviorSubject so the + // initial seed replay does not look like a transition. Avoids + // `startWith(true)`'s implicit assumption that we were previously + // connected (which gave a spurious "offline" toast on cold start). + this.lastSeenConnected = this.websocketService.isConnected; this.connectionWatchSub = this.websocketService.connected$ - .pipe(distinctUntilChanged(), startWith(true), pairwise()) - .subscribe(([prev, curr]) => { + .pipe(distinctUntilChanged()) + .subscribe(curr => { + const prev = this.lastSeenConnected; + this.lastSeenConnected = curr; + if (prev === undefined || prev === curr) return; if (prev && !curr) { this.toatsService.showToast( 'Lost connection to collaboration. Edits may not sync until reconnected.', @@ -91,22 +101,29 @@ export class CollaborationService implements OnDestroy { false ); } else if (!prev && curr && this.activeCourseIterationId) { - // Auto-reconnect succeeded — rebind the subscriptions because the - // STOMP CompatClient does not preserve them across reconnects. + // Auto-reconnect succeeded — rebind via the full connect() flow + // (discover + diff overlay) so a peer's newer state can never + // be silently overwritten by our offline-collected local edits. void this.rebindAfterReconnect(this.activeCourseIterationId); } }); } - /** Rebuild topic subscriptions and push our current state after a reconnect. */ + /** + * Reconnect rebind. Routes through the same `connect()` flow used on + * cold start so the discovery handshake + diff overlay run again — + * without that, any local edits made while offline would silently + * clobber a peer's newer state because the topic broadcasts that + * `subscribeToX` does on (re-)subscribe push the FULL local snapshot + * to the server. + */ private async rebindAfterReconnect(courseIterationId: string): Promise { for (const sub of this.subscriptions) sub.unsubscribe(); this.subscriptions = []; + this.discoverySubscription?.unsubscribe(); + this.discoverySubscription = undefined; try { - await this.subscribeToAllocations(courseIterationId); - await this.subscribeToLockedStudents(courseIterationId); - await this.subscribeToConstraints(courseIterationId); - this.toatsService.showToast('Reconnected to collaboration.', 'Collaboration', true); + await this.connect(courseIterationId); } catch { this.toatsService.showToast( 'Could not re-subscribe after reconnect. Please reconnect manually.', @@ -169,6 +186,7 @@ export class CollaborationService implements OnDestroy { this.subscriptions = []; this.connectionWatchSub?.unsubscribe(); this.connectionWatchSub = null; + this.lastSeenConnected = undefined; this.activeCourseIterationId = null; this.websocketService.connection?.disconnect(); From bf6b1e8400d8a300748d3f5bdbd8bc7e38d994bf Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Fri, 24 Apr 2026 01:11:18 +0200 Subject: [PATCH 12/14] feat(client): add global participant search to the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a header-mounted "Search participants" input that finds people across the loaded board without forcing the user to scroll teams. Scope (intentionally narrow): - First / last / full name (substring, case-insensitive). - Email (substring, prefix matches on local-part rank slightly higher). - Skill title — match anyone who has a skill whose title contains the query (e.g. "docker", "swift"). Other attributes (gender, nationality, language, devices) are filter concepts and stay in the constraint builder, not the search box. UX: - Ctrl/⌘K focuses the input from anywhere in the app. - 120 ms debounce; minimum 2-character query. - Up to 10 ranked results; each shows name, email, current team (or "unallocated"), and a small badge for which field matched. - Arrow Up/Down navigate, Enter activates, Escape closes; click outside the component closes too. - Activating a result scrolls the matching student card into view on the team-allocation board and runs a 2.5s glow/pulse highlight via a global `.global-search-highlight` class. The student-card DOM contract (`id={studentId}`) is the same one used by drag-drop. Architecture: - New `GlobalSearchService` ranks matches; pulls from the existing StudentsService / SkillsService / AllocationsService / ProjectsService (no new state, no API changes). - New `StudentHighlightService` owns the scroll-into-view + temporary CSS class. - New `GlobalSearchComponent` wraps the input + dropdown. - Component declared in ComponentsModule and rendered inside the navigation bar between the constraint indicator and the action buttons; only mounts when a course iteration is active. No backend changes. CSV-only mode also benefits — search runs against whatever students are loaded locally. --- .../src/app/components/components.module.ts | 2 + .../global-search.component.html | 58 ++++++ .../global-search.component.scss | 131 ++++++++++++++ .../global-search/global-search.component.ts | 169 ++++++++++++++++++ .../navigation-bar.component.html | 3 + .../shared/services/global-search.service.ts | 151 ++++++++++++++++ .../services/student-highlight.service.ts | 42 +++++ client/src/styles.scss | 29 +++ 8 files changed, 585 insertions(+) create mode 100644 client/src/app/components/global-search/global-search.component.html create mode 100644 client/src/app/components/global-search/global-search.component.scss create mode 100644 client/src/app/components/global-search/global-search.component.ts create mode 100644 client/src/app/shared/services/global-search.service.ts create mode 100644 client/src/app/shared/services/student-highlight.service.ts diff --git a/client/src/app/components/components.module.ts b/client/src/app/components/components.module.ts index d454c1f3..7c3af996 100644 --- a/client/src/app/components/components.module.ts +++ b/client/src/app/components/components.module.ts @@ -25,6 +25,7 @@ import { ConstraintBuilderNationalityComponent } from './constraint-builder-nati import { SelectComponent } from './select/select.component'; import { ConstraintHelpComponent } from './constraint-help/constraint-help.component'; import { ProjectPickerComponent } from './project-picker/project-picker.component'; +import { GlobalSearchComponent } from './global-search/global-search.component'; @NgModule({ declarations: [ @@ -45,6 +46,7 @@ import { ProjectPickerComponent } from './project-picker/project-picker.componen SelectComponent, ConstraintHelpComponent, ProjectPickerComponent, + GlobalSearchComponent, ], exports: [ NavigationBarComponent, diff --git a/client/src/app/components/global-search/global-search.component.html b/client/src/app/components/global-search/global-search.component.html new file mode 100644 index 00000000..3003bbe6 --- /dev/null +++ b/client/src/app/components/global-search/global-search.component.html @@ -0,0 +1,58 @@ + diff --git a/client/src/app/components/global-search/global-search.component.scss b/client/src/app/components/global-search/global-search.component.scss new file mode 100644 index 00000000..76b7ee02 --- /dev/null +++ b/client/src/app/components/global-search/global-search.component.scss @@ -0,0 +1,131 @@ +@import '/src/scss/style-constants.scss'; + +.global-search { + position: relative; + width: 22rem; + max-width: 28rem; +} + +.global-search__input-wrapper { + position: relative; +} + +.global-search__input { + width: 100%; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #fff; + padding-right: 2rem; + + &::placeholder { + color: rgba(255, 255, 255, 0.55); + } + + &:focus { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.35); + color: #fff; + box-shadow: none; + } +} + +.global-search__clear { + position: absolute; + top: 50%; + right: 0.45rem; + transform: translateY(-50%); + width: 1.4rem; + height: 1.4rem; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.7); + font-size: 1.1rem; + line-height: 1; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; + } +} + +.global-search__menu { + position: absolute; + top: calc(100% + 0.4rem); + left: 0; + right: 0; + background: #fff; + color: #1f1f1f; + border-radius: 0.5rem; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + max-height: 24rem; + overflow-y: auto; + z-index: 1050; + padding: 0.25rem 0; +} + +.global-search__empty { + padding: 0.85rem 1rem; + color: rgba(0, 0, 0, 0.55); + font-size: 0.9rem; +} + +.global-search__result { + width: 100%; + background: transparent; + border: none; + text-align: left; + padding: 0.55rem 1rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + color: inherit; + cursor: pointer; + + &:hover, + &--focused { + background: rgba(0, 0, 0, 0.05); + } +} + +.global-search__result-primary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +.global-search__result-name { + font-weight: 600; + font-size: 0.95rem; +} + +.global-search__result-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + color: rgba(0, 0, 0, 0.65); + white-space: nowrap; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.global-search__result-secondary { + font-size: 0.78rem; + color: rgba(0, 0, 0, 0.6); + display: flex; + gap: 0.4rem; + align-items: center; +} + +.global-search__result-team--unallocated { + font-style: italic; + color: rgba(0, 0, 0, 0.45); +} diff --git a/client/src/app/components/global-search/global-search.component.ts b/client/src/app/components/global-search/global-search.component.ts new file mode 100644 index 00000000..59875232 --- /dev/null +++ b/client/src/app/components/global-search/global-search.component.ts @@ -0,0 +1,169 @@ +import { + Component, + ElementRef, + HostListener, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { + GlobalSearchService, + SearchResult, +} from 'src/app/shared/services/global-search.service'; +import { StudentHighlightService } from 'src/app/shared/services/student-highlight.service'; +import { ToastsService } from 'src/app/shared/services/toasts.service'; + +/** + * Header-mounted participant search. + * + * UX: + * - Type ≥ 2 chars → debounced search across loaded students + * (name / email / skill title — see GlobalSearchService for scope). + * - Arrow Up/Down navigates the dropdown, Enter activates the focused + * result (or the top result if none focused), Escape closes it. + * - Clicking a result scrolls the matching card into view on the + * team-allocation board and applies a brief highlight pulse. + */ +@Component({ + selector: 'app-global-search', + templateUrl: './global-search.component.html', + styleUrls: ['./global-search.component.scss'], + standalone: false, +}) +export class GlobalSearchComponent implements OnInit, OnDestroy { + /** Search-input form control bound to the textbox. */ + readonly query = new FormControl('', { nonNullable: true }); + + /** Latest search results to render in the dropdown. */ + results: SearchResult[] = []; + + /** True while the dropdown should be visible. */ + open = false; + + /** Index of the keyboard-focused result, or -1 for none. */ + focusedIndex = -1; + + @ViewChild('queryInput') queryInput!: ElementRef; + + private readonly query$ = new Subject(); + private subscription: Subscription | null = null; + + constructor( + private readonly globalSearchService: GlobalSearchService, + private readonly studentHighlightService: StudentHighlightService, + private readonly toastsService: ToastsService, + private readonly hostElement: ElementRef + ) {} + + ngOnInit(): void { + this.subscription = this.query$ + .pipe(debounceTime(120), distinctUntilChanged()) + .subscribe(value => this.runSearch(value)); + + this.subscription.add( + this.query.valueChanges.subscribe(value => this.query$.next(value ?? '')) + ); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } + + /** Click outside the search component → close the dropdown. */ + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.open) return; + if (!this.hostElement.nativeElement.contains(event.target as Node)) { + this.close(); + } + } + + /** Global keyboard shortcut: Ctrl/Cmd+K focuses the search input. */ + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + this.queryInput?.nativeElement.focus(); + this.queryInput?.nativeElement.select(); + } + } + + /** Open the dropdown when the input gains focus and a query is present. */ + onFocus(): void { + if (this.query.value && this.query.value.trim().length >= 2) { + this.open = true; + } + } + + /** Inline keyboard handling for the input itself (arrows / enter / esc). */ + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + return; + } + if (!this.results.length) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.focusedIndex = (this.focusedIndex + 1) % this.results.length; + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.focusedIndex = + (this.focusedIndex - 1 + this.results.length) % this.results.length; + } else if (event.key === 'Enter') { + event.preventDefault(); + const target = this.results[this.focusedIndex >= 0 ? this.focusedIndex : 0]; + if (target) this.activate(target); + } + } + + /** Render-side click handler. */ + activate(result: SearchResult): void { + if (!result) return; + const ok = this.studentHighlightService.highlight(result.studentId); + if (!ok) { + this.toastsService.showToast( + `Could not locate "${result.displayName}" on the board. They may be unallocated or off-screen.`, + 'Participant search', + false + ); + } + this.close(); + } + + /** Clear-button handler — also closes the dropdown. */ + clear(): void { + this.query.setValue(''); + this.results = []; + this.focusedIndex = -1; + this.open = false; + this.queryInput?.nativeElement.focus(); + } + + /** Label for the small "where the match came from" badge per result. */ + matchedFieldLabel(result: SearchResult): string { + switch (result.matchedField) { + case 'name': + return 'Name'; + case 'email': + return 'Email'; + case 'skill': + return result.matchedValue ? `Skill · ${result.matchedValue}` : 'Skill'; + } + } + + private runSearch(value: string): void { + this.results = this.globalSearchService.search(value); + this.focusedIndex = this.results.length ? 0 : -1; + this.open = (value?.trim().length ?? 0) >= 2; + } + + private close(): void { + this.open = false; + this.focusedIndex = -1; + } +} diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.html b/client/src/app/components/navigation-bar/navigation-bar.component.html index 79b7efa1..43081b15 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.html +++ b/client/src/app/components/navigation-bar/navigation-bar.component.html @@ -27,6 +27,9 @@
+ @if (allocationData.courseIteration) { + + } @if (!fulfillsAllConstraints) {
[skill.id, skill]) + ); + const teamLookup = this.buildStudentToTeamLookup(); + + const results: SearchResult[] = []; + + for (const student of students) { + const fullName = `${student.firstName ?? ''} ${student.lastName ?? ''}`.trim(); + const fullNameLower = fullName.toLowerCase(); + const emailLower = (student.email ?? '').toLowerCase(); + + // Name matches outrank email/skill matches. + if (fullNameLower.includes(query)) { + const score = this.nameScore(fullNameLower, query); + results.push(this.toResult(student.id, fullName, student.email, teamLookup, 'name', undefined, score)); + continue; + } + + if (emailLower && emailLower.includes(query)) { + // Prefix matches on the email's local-part rank slightly higher. + const score = emailLower.startsWith(query) ? 70 : 60; + results.push(this.toResult(student.id, fullName, student.email, teamLookup, 'email', undefined, score)); + continue; + } + + const matchedSkill = (student.skills ?? []) + .map(s => skillIndex.get(s.id)) + .find(skill => skill && skill.title?.toLowerCase().includes(query)); + if (matchedSkill) { + results.push( + this.toResult(student.id, fullName, student.email, teamLookup, 'skill', matchedSkill.title, 50) + ); + } + } + + return results + .sort((a, b) => b.score - a.score || a.displayName.localeCompare(b.displayName)) + .slice(0, GlobalSearchService.MAX_RESULTS); + } + + /** Build a `studentId → teamName` lookup from the current allocation state. */ + private buildStudentToTeamLookup(): Map { + const lookup = new Map(); + for (const allocation of this.allocationsService.getAllocations() ?? []) { + const teamName = this.projectsService.getProjectNameById(allocation.projectId); + for (const studentId of allocation.students ?? []) { + lookup.set(studentId, teamName); + } + } + return lookup; + } + + private nameScore(fullNameLower: string, query: string): number { + if (fullNameLower === query) return 200; + if (fullNameLower.startsWith(query)) return 150; + // Word-boundary match (e.g. matches the start of last name). + if (new RegExp(`\\b${this.escapeRegex(query)}`).test(fullNameLower)) return 120; + return 100; + } + + private toResult( + studentId: string, + displayName: string, + email: string, + teamLookup: Map, + matchedField: SearchMatchField, + matchedValue: string | undefined, + score: number + ): SearchResult { + return { + studentId, + displayName, + email, + teamName: teamLookup.get(studentId) ?? null, + matchedField, + matchedValue, + score, + }; + } + + private escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} diff --git a/client/src/app/shared/services/student-highlight.service.ts b/client/src/app/shared/services/student-highlight.service.ts new file mode 100644 index 00000000..70f29038 --- /dev/null +++ b/client/src/app/shared/services/student-highlight.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; + +/** Highlight effect duration (ms) — class is removed after this. */ +const HIGHLIGHT_DURATION_MS = 2500; + +/** CSS class applied to the matching student card; styled in `styles.scss`. */ +const HIGHLIGHT_CLASS = 'global-search-highlight'; + +/** + * Locates a student's card on the team-allocation board and draws + * attention to it: scrolls into view, then applies a temporary CSS + * class for a glow / pulse animation. + * + * The DOM contract: each student card carries `id={studentId}` + * (already enforced by the existing drag-drop wiring in + * `app.component.ts:handleStudentDrop`). + */ +@Injectable({ + providedIn: 'root', +}) +export class StudentHighlightService { + highlight(studentId: string): boolean { + if (!studentId) return false; + if (typeof document === 'undefined') return false; + + const element = document.getElementById(studentId); + if (!element) return false; + + element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + + element.classList.remove(HIGHLIGHT_CLASS); // restart the animation if it's still playing + // Force reflow so removing then re-adding the class re-triggers CSS animation. + void element.offsetWidth; + element.classList.add(HIGHLIGHT_CLASS); + + window.setTimeout(() => { + element.classList.remove(HIGHLIGHT_CLASS); + }, HIGHLIGHT_DURATION_MS); + + return true; + } +} diff --git a/client/src/styles.scss b/client/src/styles.scss index f7e42bed..f3c839e2 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -16,3 +16,32 @@ body { right: 0; bottom: 0; } + +// --- Global search highlight --------------------------------------------- +// +// Applied by `StudentHighlightService` to a student card after the user +// jumps to it from the participant search dropdown. Lives at the global +// scope because the target elements live inside team / project subtrees +// that don't share a stylesheet with the navigation header. +.global-search-highlight { + animation: global-search-pulse 2.5s ease-out; + position: relative; + z-index: 5; +} + +@keyframes global-search-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 200, 0, 0.85); + outline: 2px solid rgba(255, 200, 0, 0.85); + outline-offset: 2px; + } + 60% { + box-shadow: 0 0 0 12px rgba(255, 200, 0, 0); + outline-color: rgba(255, 200, 0, 0.85); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 200, 0, 0); + outline: 2px solid rgba(255, 200, 0, 0); + outline-offset: 2px; + } +} From 6e39fd2819b77275e575ba0cfe05af89a0e33dba Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Fri, 24 Apr 2026 14:49:47 +0200 Subject: [PATCH 13/14] fix(global-search): trim resting width + expand on focus --- .../global-search/global-search.component.html | 2 +- .../global-search/global-search.component.scss | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/app/components/global-search/global-search.component.html b/client/src/app/components/global-search/global-search.component.html index 3003bbe6..fc98ed48 100644 --- a/client/src/app/components/global-search/global-search.component.html +++ b/client/src/app/components/global-search/global-search.component.html @@ -4,7 +4,7 @@ #queryInput type="search" class="form-control global-search__input" - placeholder="Search participants… (Ctrl/⌘K)" + placeholder="Search participants…" [formControl]="query" (focus)="onFocus()" (keydown)="onKeydown($event)" diff --git a/client/src/app/components/global-search/global-search.component.scss b/client/src/app/components/global-search/global-search.component.scss index 76b7ee02..7d8276a5 100644 --- a/client/src/app/components/global-search/global-search.component.scss +++ b/client/src/app/components/global-search/global-search.component.scss @@ -2,8 +2,18 @@ .global-search { position: relative; - width: 22rem; - max-width: 28rem; + // Compact resting width so the header keeps room for the action + // cluster on standard 1366–1920 viewports. Expands on focus so the + // user gets more real estate while typing without permanently + // pushing the other controls onto a new flex line. + width: 14rem; + max-width: 18rem; + transition: width 0.18s ease; + + &:focus-within { + width: 20rem; + max-width: 22rem; + } } .global-search__input-wrapper { From 73703edb20b338f9091fc155586b44dea6f585e9 Mon Sep 17 00:00:00 2001 From: Joshua Law Date: Fri, 24 Apr 2026 15:34:26 +0200 Subject: [PATCH 14/14] refactor(client): regroup workspace + collab status under project name Header layout reshuffle to free up the right-hand action cluster: - Workspace status pill (Saving / Unsaved / Saved / Save failed) and the collaboration indicator both move from the right cluster to a small sub-row under the project name on the left. - Collaboration indicator becomes a compact "Live" / "Offline" pill styled to match the workspace pill (same border-radius, font-size, padding) instead of the standalone fa-icon. Click action unchanged. - Right cluster now holds just the primary "Save Allocations" button (no stacked sub-pill), so the row stays single-line at typical widths. - All four workspace pill states harmonised to font-size 0.72rem / padding 0.1rem 0.6rem. The clickable button pills (Unsaved / Save failed) explicitly re-assert font-size + padding so the user-agent button styling no longer enlarges them via `font: inherit`. - Global search resting width trimmed from 14rem to 11rem so the placeholder hugs the input; focus-expansion to 20rem unchanged. --- .../global-search.component.scss | 11 +- .../navigation-bar.component.html | 151 ++++++++++-------- .../navigation-bar.component.scss | 78 ++++++--- 3 files changed, 142 insertions(+), 98 deletions(-) diff --git a/client/src/app/components/global-search/global-search.component.scss b/client/src/app/components/global-search/global-search.component.scss index 7d8276a5..01b63b08 100644 --- a/client/src/app/components/global-search/global-search.component.scss +++ b/client/src/app/components/global-search/global-search.component.scss @@ -2,12 +2,11 @@ .global-search { position: relative; - // Compact resting width so the header keeps room for the action - // cluster on standard 1366–1920 viewports. Expands on focus so the - // user gets more real estate while typing without permanently - // pushing the other controls onto a new flex line. - width: 14rem; - max-width: 18rem; + // Resting width hugs the placeholder text ("Search participants…") so + // the input doesn't dominate the action cluster. Expands on focus to + // give plenty of room while typing. + width: 11rem; + max-width: 13rem; transition: width 0.18s ease; &:focus-within { diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.html b/client/src/app/components/navigation-bar/navigation-bar.component.html index 43081b15..d02de0c6 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.html +++ b/client/src/app/components/navigation-bar/navigation-bar.component.html @@ -3,26 +3,79 @@
TEASE
@if (allocationData.courseIteration) { -
- {{ allocationData.courseIteration.semesterName }} -
+
+
+ {{ allocationData.courseIteration.semesterName }} +
+
+ @if (websocketService.connected$ | async) { + + } @else { + + } - @if (websocketService.connected$ | async) { - - } @else { -
- + @if (workspaceActive$ | async) { + @switch (saveStatusLabel$ | async) { + @case ('saving') { +
+ + Saving Workspace… +
+ } + @case ('error') { + + } + @case ('unsaved') { + + } + @case ('saved') { +
+ + Workspace Saved +
+ } + } + }
- } +
}
@@ -61,59 +114,15 @@ @if (workspaceActive$ | async) { -
- @switch (saveStatusLabel$ | async) { - @case ('saving') { -
- - Saving Workspace... -
- } - @case ('error') { - - } - @case ('unsaved') { - - } - @case ('saved') { -
- - Workspace Saved -
- } - } - -
+ }
diff --git a/client/src/app/components/navigation-bar/navigation-bar.component.scss b/client/src/app/components/navigation-bar/navigation-bar.component.scss index e9f9e18c..97e59148 100644 --- a/client/src/app/components/navigation-bar/navigation-bar.component.scss +++ b/client/src/app/components/navigation-bar/navigation-bar.component.scss @@ -1,24 +1,58 @@ @import '/src/scss/style-constants.scss'; -.collaboration-indicator { - font-size: 1.65rem; +.wh-4 { + width: 2rem; + height: 2rem; } -.collaboration-indicator.success { - color: green; +// --- Project meta cluster (project name + collab + workspace pill) ------- + +.project-meta { + // Sub-row sits flush under the semester name; small text so it reads + // as metadata, not as a primary action. + line-height: 1; } -.collaboration-indicator.error { - color: $warn; +.project-meta__row { + // Tighter so the pills feel attached to the project label rather + // than floating in their own row. + margin-top: 0.05rem; } -.collaboration-indicator:hover.error { - color: $primary; +// --- Collaboration "Live / Offline" pill --------------------------------- + +.collab-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.1rem 0.6rem; + border-radius: 999px; + font-size: 0.72rem; + line-height: 1.1; + border: none; + cursor: pointer; + white-space: nowrap; + transition: filter 0.15s ease; } -.wh-4 { - width: 2rem; - height: 2rem; +.collab-pill__icon { + font-size: 0.85rem; +} + +.collab-pill--connected { + color: #7adf97; + background: rgba(122, 223, 151, 0.12); +} + +.collab-pill--disconnected { + color: #ff9f7a; + background: rgba(255, 159, 122, 0.14); +} + +.collab-pill:hover, +.collab-pill:focus { + filter: brightness(1.15); + outline: none; } // --- Project-switcher dropdown ------------------------------------------ @@ -79,20 +113,16 @@ font-style: italic; } -// --- Save-to-PROMPT status cluster --------------------------------------- - -.save-cluster { - border-left: 1px solid rgba(255, 255, 255, 0.15); - padding-left: 0.75rem; -} +// --- Workspace save-status pill (sits in the project-meta row) ----------- .save-status { display: inline-flex; align-items: center; gap: 0.4rem; - padding: 0.25rem 0.75rem; + padding: 0.1rem 0.6rem; border-radius: 999px; - font-size: 0.85rem; + font-size: 0.72rem; + line-height: 1.1; color: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.08); white-space: nowrap; @@ -139,10 +169,16 @@ animation: save-blink 1s ease-in-out infinite; } -// Button reset for the clickable "Unsaved changes" pill. +// Button reset for the clickable "Unsaved changes" / "Save failed" +// pills — `font: inherit` would pull the parent body font-size back in +// and visibly enlarge the pill, so re-assert the compact size + padding +// explicitly here. button.save-status { border: none; - font: inherit; + font-family: inherit; + font-size: 0.72rem; + line-height: 1.1; + padding: 0.1rem 0.6rem; cursor: pointer; }