Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/api/models/doubtfire-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ export * from '../services/task-similarity.service';
export * from '../services/test-attempt.service';
export * from '../models/d2l/d2l_assessment_mapping.service';
export * from '../services/feedback-template.service';

// Engagement heatmap -- unit-specific task activity for a project
export * from './engagement-heatmap';
export * from '../services/engagement-heatmap.service';
37 changes: 37 additions & 0 deletions src/app/api/models/engagement-heatmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Shape returned by `GET /api/projects/:id/engagement_heatmap`.
*
* This mirrors the backend `EngagementHeatmapService.build(project: ...)`
* contract (doubtfire-api). Keep this interface and the backend presenter in
* sync when the contract changes.
*/

export interface EngagementHeatmapDay {
/** ISO date string, e.g. "2026-01-21". */
date: string;
/** Count of activity events on that day for this project only. */
activity_count: number;
}

export interface EngagementHeatmapRange {
/** Inclusive start date of the window (ISO date string). */
start_date: string;
/** Inclusive end date of the window (ISO date string). */
end_date: string;
/** Number of days in the window (typically 84). */
days: number;
}

export interface EngagementHeatmapSummary {
tasks_completed: number;
active_days: number;
current_streak: number;
}

export interface EngagementHeatmapResponse {
project_id: number;
unit_id: number;
range: EngagementHeatmapRange;
days: EngagementHeatmapDay[];
summary: EngagementHeatmapSummary;
}
112 changes: 112 additions & 0 deletions src/app/api/services/engagement-heatmap.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {TestBed} from '@angular/core/testing';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';

import API_URL from 'src/app/config/constants/apiUrl';
import {EngagementHeatmapResponse} from '../models/engagement-heatmap';
import {EngagementHeatmapService} from './engagement-heatmap.service';

function buildResponse(projectId: number): EngagementHeatmapResponse {
return {
project_id: projectId,
unit_id: 7,
range: {start_date: '2026-01-21', end_date: '2026-04-14', days: 84},
days: [],
summary: {tasks_completed: 0, active_days: 0, current_streak: 0},
};
}

describe('EngagementHeatmapService', () => {
let service: EngagementHeatmapService;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [EngagementHeatmapService],
});
service = TestBed.inject(EngagementHeatmapService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify();
});

it('fetches from the backend on a cold cache and emits the response', () => {
const received: EngagementHeatmapResponse[] = [];
service.get(42).subscribe((r) => received.push(r));

const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`);
expect(req.request.method).toBe('GET');
req.flush(buildResponse(42));

expect(received.length).toBe(1);
expect(received[0].project_id).toBe(42);
});

it('serves a subsequent call from cache without a second HTTP request', () => {
service.get(42).subscribe();
const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`);
req.flush(buildResponse(42));

const received: EngagementHeatmapResponse[] = [];
service.get(42).subscribe((r) => received.push(r));

httpMock.expectNone(`${API_URL}/projects/42/engagement_heatmap`);
expect(received.length).toBe(1);
expect(received[0].project_id).toBe(42);
});

it('caches each project independently', () => {
service.get(1).subscribe();
httpMock.expectOne(`${API_URL}/projects/1/engagement_heatmap`).flush(buildResponse(1));

service.get(2).subscribe();
httpMock.expectOne(`${API_URL}/projects/2/engagement_heatmap`).flush(buildResponse(2));

// Re-request project 1 — should hit cache, not refetch.
service.get(1).subscribe();
httpMock.expectNone(`${API_URL}/projects/1/engagement_heatmap`);
});

it('invalidate(projectId) forces the next call to refetch', () => {
service.get(42).subscribe();
httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`).flush(buildResponse(42));

service.invalidate(42);

service.get(42).subscribe();
const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`);
req.flush(buildResponse(42));
});

it('invalidate() with no argument clears the entire cache', () => {
service.get(1).subscribe();
httpMock.expectOne(`${API_URL}/projects/1/engagement_heatmap`).flush(buildResponse(1));
service.get(2).subscribe();
httpMock.expectOne(`${API_URL}/projects/2/engagement_heatmap`).flush(buildResponse(2));

service.invalidate();

service.get(1).subscribe();
httpMock.expectOne(`${API_URL}/projects/1/engagement_heatmap`).flush(buildResponse(1));
service.get(2).subscribe();
httpMock.expectOne(`${API_URL}/projects/2/engagement_heatmap`).flush(buildResponse(2));
});

it('refetches once the cached entry is older than the TTL', () => {
// Freeze time so the TTL test is deterministic.
const base = 1_700_000_000_000;
spyOn(Date, 'now').and.returnValue(base);

service.get(42).subscribe();
httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`).flush(buildResponse(42));

// Advance past the TTL (default 60 s).
(Date.now as jasmine.Spy).and.returnValue(base + EngagementHeatmapService.CACHE_TTL_MS + 1);

service.get(42).subscribe();
const req = httpMock.expectOne(`${API_URL}/projects/42/engagement_heatmap`);
req.flush(buildResponse(42));
});
});
73 changes: 73 additions & 0 deletions src/app/api/services/engagement-heatmap.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable, of} from 'rxjs';
import {tap} from 'rxjs/operators';
import API_URL from 'src/app/config/constants/apiUrl';
import {EngagementHeatmapResponse} from '../models/engagement-heatmap';

/**
* Thin read-only client for the unit-specific engagement heatmap endpoint.
*
* Matches the pattern used by small read-only endpoints elsewhere in the app
* (see `TiiService`, `UnitService.loadLearningProgressClassStats`, etc.): a
* root-provided service with a single `httpClient.get<T>` call. We intentionally
* do not go through `CachedEntityService` because the payload is derived /
* non-CRUD.
*
* A lightweight in-memory cache keyed by `projectId` sits in front of the HTTP
* call. Its purpose is *perceived load speed* — a student who navigates away
* from the Peer Progress page and back within a minute gets the card
* re-rendered from cache without the network round trip. The TTL is short
* enough that activity shown on the heatmap stays roughly fresh without
* requiring manual invalidation hooks elsewhere in the app.
*/
@Injectable({
providedIn: 'root',
})
export class EngagementHeatmapService {
/** How long a cached response is considered "fresh" (milliseconds). */
public static readonly CACHE_TTL_MS = 60_000;

private readonly cache = new Map<
number,
{data: EngagementHeatmapResponse; fetchedAt: number}
>();

constructor(private httpClient: HttpClient) {}

/**
* Fetch the engagement heatmap for a single project/unit context.
*
* Returns a cached response synchronously (via `of(...)`) when one exists
* and is still within the TTL; otherwise falls through to the HTTP request
* and caches the result on success.
*
* The heatmap is intentionally scoped to one project (i.e. one unit) — it is
* never aggregated across a student's multiple units.
*/
public get(projectId: number): Observable<EngagementHeatmapResponse> {
const cached = this.cache.get(projectId);
if (cached && Date.now() - cached.fetchedAt < EngagementHeatmapService.CACHE_TTL_MS) {
return of(cached.data);
}

const url = `${API_URL}/projects/${projectId}/engagement_heatmap`;
return this.httpClient.get<EngagementHeatmapResponse>(url).pipe(
tap((data) => {
this.cache.set(projectId, {data, fetchedAt: Date.now()});
}),
);
}

/**
* Evict cached entries. Pass a `projectId` to evict a single entry, or call
* with no arguments to clear the whole cache (e.g. on logout).
*/
public invalidate(projectId?: number): void {
if (projectId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(projectId);
}
}
13 changes: 11 additions & 2 deletions src/app/common/header/task-dropdown/task-dropdown.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
>
<mat-icon aria-label="Dashboard icon" fontIcon="dashboard"></mat-icon> Dashboard
</button>
<button
uiSref="projects/peer-progress"
[uiParams]="{projectId: currentProject.id}"
mat-menu-item
>
<mat-icon aria-label="Peer Progress icon" fontIcon="insights"></mat-icon>
Peer Progress
</button>

<mat-divider></mat-divider>
<button uiSref="project/plan" [uiParams]="{projectId: currentProject.id}" mat-menu-item>
<mat-icon aria-label="Plan icon" fontIcon="calendar_month"></mat-icon>
Expand Down Expand Up @@ -133,8 +142,8 @@
<mat-icon aria-label="Task list icon" fontIcon="list_alt"></mat-icon> Tasks
</button>
<button uiSref="tutor-discussion" [uiParams]="{unitId: unitRole.unit.id}" mat-menu-item>
<mat-icon aria-label="Tutor Discussion icon" fontIcon="qr_code_scanner"></mat-icon
>Discussion
<mat-icon aria-label="Tutor Discussion icon" fontIcon="qr_code_scanner"></mat-icon>
Discussion
</button>
<button (click)="openTutorNotes()" mat-menu-item>
<mat-icon aria-label="Tutor Notes icon" fontIcon="local_library"></mat-icon>My Tutor Notes
Expand Down
4 changes: 4 additions & 0 deletions src/app/doubtfire-angular.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ import {TaskDateSliderComponent} from './common/modals/date-change-modal/task-da
import {SpecConModalComponent} from './common/modals/spec-con-modal/spec-con-modal.component';
import {SpecConModalService} from './common/modals/spec-con-modal/spec-con-modal.service';
import {ProjectPlanComponent} from './projects/states/plan/project-plan.component';
import {PeerProgressComponent} from './projects/states/peer-progress/peer-progress.component';
import {EngagementHeatmapCardComponent} from './projects/states/peer-progress/widgets/engagement-heatmap-card/engagement-heatmap-card.component';
import {JplagReportViewerComponent} from './projects/states/jplag/jplag-report-viewer.component';
import {StaffNotesComponent} from './projects/states/staff-notes/staff-notes.component';
import {StaffNoteService} from './api/services/staff-note.service';
Expand Down Expand Up @@ -390,6 +392,8 @@ const GANTT_CHART_CONFIG = {
ConfirmationModalComponent,
InstitutionSettingsComponent,
ProjectPlanComponent,
PeerProgressComponent,
EngagementHeatmapCardComponent,
SuccessCloseComponent,
HomeComponent,
CommentBubbleActionComponent,
Expand Down
20 changes: 20 additions & 0 deletions src/app/doubtfire.states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component
import {TutorDiscussionComponent} from './projects/states/tutor-discussion/tutor-discussion.component';
import {SuccessCloseComponent} from './common/success-close/success-close.component';
import {ProjectPlanComponent} from './projects/states/plan/project-plan.component';
import {PeerProgressComponent} from './projects/states/peer-progress/peer-progress.component';
import {JplagReportViewerComponent} from './projects/states/jplag/jplag-report-viewer.component';
import {LtiDashboardComponent} from './home/states/lti-dashboard/lti-dashboard.component';
import {LtiUnitLinkComponent} from './home/states/lti-unit-link/lti-unit-link.component';
Expand Down Expand Up @@ -447,6 +448,24 @@ const projectPlanState: NgHybridStateDeclaration = {
},
};

/**
* Dedicated "Peer Progress" page for the selected project. Hosts the engagement
* heatmap and future peer progress widgets. Parented on `projects/index` so the
* Project (and its Unit) are resolved and broadcast via GlobalStateService before
* the view renders.
*/
const peerProgressState: NgHybridStateDeclaration = {
name: 'projects/peer-progress',
parent: 'projects/index',
url: '/peer-progress',
component: PeerProgressComponent,
data: {
pageTitle: 'Peer Progress',
task: 'Peer Progress',
roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin', 'Auditor'],
},
};

const TutorDiscussionState: NgHybridStateDeclaration = {
name: 'tutor-discussion',
url: '/tutor-discussion?unitId&username',
Expand Down Expand Up @@ -594,6 +613,7 @@ export const doubtfireStates = [
ScormPlayerStudentReviewState,
SuccessCloseState,
projectPlanState,
peerProgressState,
TutorDiscussionState,
jplagReportViewerState,
LtiDashboardState,
Expand Down
30 changes: 30 additions & 0 deletions src/app/projects/states/peer-progress/peer-progress.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div class="container mx-auto p-6">
<header class="mb-6">
<h2 class="mat-headline-5 m-0">Peer Progress</h2>
@if (project?.unit) {
<p class="mat-lead m-0 mt-1">
{{ project.unit.code }} &mdash; {{ project.unit.name }}
</p>
}
</header>

@if (!project) {
<div class="flex items-center justify-center" style="min-height: 200px">
<mat-progress-spinner diameter="40" mode="indeterminate"></mat-progress-spinner>
</div>
} @else {
<section
class="grid grid-cols-1 xl:grid-cols-2 gap-6"
aria-label="Peer progress widgets"
>
<f-engagement-heatmap-card
[project]="project"
class="xl:col-span-2"
></f-engagement-heatmap-card>
<!--
Future peer progress widgets slot in here — each takes [project] as
its single input and owns its own loading / empty / error states.
-->
</section>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:host {
display: block;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BehaviorSubject} from 'rxjs';
import {MatCardModule} from '@angular/material/card';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';

import {PeerProgressComponent} from './peer-progress.component';
import {GlobalStateService} from '../index/global-state.service';

describe('PeerProgressComponent', () => {
let component: PeerProgressComponent;
let fixture: ComponentFixture<PeerProgressComponent>;

beforeEach(async () => {
const globalStateServiceStub = {
currentViewAndEntitySubject$: new BehaviorSubject<{viewType: string; entity: unknown} | null>(
null,
),
};

await TestBed.configureTestingModule({
declarations: [PeerProgressComponent],
imports: [MatCardModule, MatProgressSpinnerModule, NoopAnimationsModule],
providers: [{provide: GlobalStateService, useValue: globalStateServiceStub}],
}).compileComponents();

fixture = TestBed.createComponent(PeerProgressComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Loading
Loading