Skip to content

Commit e12b5f8

Browse files
authored
Merge pull request #77 from ontrack-features-t2-2026/fix/ppi-live-task-adapter-20260824
feat(ppi): connect task widget to authorised live endpoint
2 parents e4c0a50 + 641ff27 commit e12b5f8

10 files changed

Lines changed: 162 additions & 150 deletions

File tree

‎docs/ppi-f02-unit-summary-handover.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,19 @@ The future frontend adapter can replace the mock call inside
8181
`ProgressDashboardComponent.loadPeerProgressUnitSummary()` without redesigning
8282
the presentational component.
8383

84-
### Nullability the frontend types do not yet allow
84+
### Task-level adapter nullability
8585

8686
doubtfire-api#16 returns `null` for `last_updated_at` and `target_grade` in
87-
several documented states. Before the adapter lands, `PeerProgressIndicator`
88-
needs `lastUpdatedAt: string | null` and `targetGrade: number | null`.
87+
several documented states. The task-level live adapter now maps those values to
88+
`PeerProgressIndicator.lastUpdatedAt: string | null` and
89+
`PeerProgressIndicator.targetGrade: number | null`.
8990

9091
### The mock must not reach a production build
9192

92-
Both PPI surfaces render a fixture today: this component and the already-merged
93-
`f-ppi-widget` on the task sheet. Both are now gated on
93+
The unit-level summary in this document remains mock-backed and is gated on
9494
`environment.production`, so a built application shows the unavailable state
95-
rather than a fabricated percentage. Remove both guards in the same change that
96-
lands the live adapter, and not before.
95+
rather than a fabricated percentage. The task-sheet `f-ppi-widget` now uses the
96+
authorised project/task API route and does not render a fixture in production.
9797

9898
## Validation
9999

‎docs/ppi-help-text-pack.md‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ These rules apply to both views:
3333
1. A group result is shown as one combined percentage.
3434
2. It must not display names, student IDs, project IDs, individual results, individual
3535
task statuses, marks, feedback, raw group size or rankings.
36-
3. A genuine `0%` is a real value. It must not be presented as missing, suppressed or
37-
unavailable data.
36+
3. A displayed `0%` is a real privacy-bucket value. It must not be presented as missing,
37+
suppressed or unavailable data, but it may represent a small percentage that rounds to zero.
3838
4. A suppressed result explains that the group value is hidden for privacy without
3939
revealing the exact group size.
4040
5. Unavailable and stale states describe a data or timing condition. They must not sound
@@ -124,16 +124,16 @@ Suggested accessible name:
124124
same target grade. It does not show names, individual results, raw group size or
125125
rankings. It does not assess your own work or predict your grade.
126126

127-
### 3.2 Genuine zero result
127+
### 3.2 Rounded zero result
128128

129129
**Template state:** `@case ('no-data')`
130130

131131
- **Label:** Peer task progress
132-
- **Tooltip or short help text:** The group percentage is currently 0%. This is a real
133-
result, not missing data.
134-
- **Expanded explanation:** No students in the privacy-protected group have submitted
135-
this task yet. This is not feedback about your own progress. The percentage may change
136-
as submissions are made.
132+
- **Tooltip or short help text:** The privacy-protected group percentage currently rounds
133+
to 0%. This is a displayed value, not missing data.
134+
- **Expanded explanation:** The API rounds percentages into privacy-safe bands, so 0% may
135+
include a small number of submissions. It is not feedback about your own progress. The
136+
displayed percentage may change as submissions are made.
137137

138138
### 3.3 Small-group suppression
139139

@@ -204,14 +204,14 @@ claim that live unit-level data has been delivered.
204204
and does not show names, individual results, raw group size, marks, feedback or
205205
rankings.
206206

207-
### 4.2 Genuine zero group result
207+
### 4.2 Rounded zero group result
208208

209209
- **Label:** Unit progress overview
210-
- **Tooltip or short help text:** Anonymous group progress is currently 0%. This is a real
211-
value, not missing data.
210+
- **Tooltip or short help text:** Anonymous group progress currently rounds to 0%. This is
211+
a displayed value, not missing data.
212212
- **Expanded explanation:** Your own progress remains separate from the group result. A
213-
group value of 0% only describes the current aggregate. It is not feedback about your
214-
work and does not predict your grade.
213+
displayed group value of 0% may include a small value that rounds to zero. It is not
214+
feedback about your work and does not predict your grade.
215215

216216
### 4.3 Small-group suppression
217217

‎src/app/api/fixtures/peer-progress-indicator.fixtures.spec.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
const assertContractShape = (fixture: PeerProgressIndicator) => {
1414
expect(typeof fixture.taskDefinitionId).toBe('number');
1515
expect(typeof fixture.unitId).toBe('number');
16-
expect(typeof fixture.targetGrade).toBe('number');
16+
expect(fixture.targetGrade === null || typeof fixture.targetGrade === 'number').toBe(true);
1717

1818
// submittedPercentage can be number or null
1919
expect(
@@ -24,7 +24,7 @@ const assertContractShape = (fixture: PeerProgressIndicator) => {
2424
expect(typeof fixture.isStale).toBe('boolean');
2525
expect(typeof fixture.isFeatureEnabled).toBe('boolean');
2626

27-
expect(typeof fixture.lastUpdatedAt).toBe('string');
27+
expect(fixture.lastUpdatedAt === null || typeof fixture.lastUpdatedAt === 'string').toBe(true);
2828
expect(typeof fixture.unavailableMessage).toBe('string');
2929
};
3030

@@ -61,7 +61,7 @@ describe('PeerProgressIndicator Fixture Regression Tests', () => {
6161
expect(FIXTURE_NORMAL.isFeatureEnabled).toBe(true);
6262
});
6363

64-
it('ZERO_PERCENT fixture correctly represents genuine 0%', () => {
64+
it('ZERO_PERCENT fixture keeps a displayed 0% distinct from unavailable data', () => {
6565
expect(FIXTURE_ZERO_PERCENT.submittedPercentage).toBe(0);
6666
expect(FIXTURE_ZERO_PERCENT.isSuppressed).toBe(false);
6767
});

‎src/app/api/models/peer-progress-indicator-state.spec.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('resolvePeerProgressState', () => {
2828
expect(result.data).toEqual(NORMAL_STATE);
2929
});
3030

31-
it('returns no-data when nobody has submitted yet', () => {
31+
it('keeps a rounded zero distinct from unavailable data', () => {
3232
const result = resolvePeerProgressState(false, null, ZERO_PERCENT_STATE);
3333
expect(result.state).toBe('no-data');
3434
});
@@ -39,6 +39,16 @@ describe('resolvePeerProgressState', () => {
3939
expect(result.message).toBe(SUPPRESSED_STATE.unavailableMessage);
4040
});
4141

42+
it('prioritises privacy suppression when a response is also stale', () => {
43+
const result = resolvePeerProgressState(false, null, {
44+
...STALE_STATE,
45+
isSuppressed: true,
46+
});
47+
48+
expect(result.state).toBe('hidden');
49+
expect(result.data?.submittedPercentage).toBeNull();
50+
});
51+
4252
it('returns unavailable for a generically unavailable response', () => {
4353
const result = resolvePeerProgressState(false, null, UNAVAILABLE_STATE);
4454
expect(result.state).toBe('unavailable');
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
export interface PeerProgressIndicator {
22
taskDefinitionId: number; // ID of the task definition
33
unitId: number; // ID of the unit
4-
targetGrade: number; // Student’s selected target grade (0–3)
4+
targetGrade: number | null; // Valid server-side target grade, or null when unavailable
55

66
submittedPercentage: number | null; // null when suppressed/unavailable/disabled
77
isSuppressed: boolean; // true when cohort too small
88
isStale: boolean; // true when data is outdated
99
isFeatureEnabled: boolean; // false when unit disables PPI
1010

11-
lastUpdatedAt: string; // ISO timestamp
11+
lastUpdatedAt: string | null; // ISO timestamp, or null when no snapshot was used
1212
unavailableMessage: string; // safe message for suppressed/unavailable/stale/disabled
1313
}

‎src/app/api/services/peer-progress-indicator.service.ts‎

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import {HttpClient} from '@angular/common/http';
12
import {Injectable} from '@angular/core';
2-
import {Observable, of} from 'rxjs';
3+
import {Observable, map, of} from 'rxjs';
4+
import API_URL from 'src/app/config/constants/apiUrl';
35
import {PeerProgressIndicator} from '../models/peer-progress-indicator';
46
import {PeerProgressUnitSummary} from '../models/peer-progress-unit-summary';
57
import {
@@ -11,28 +13,40 @@ import {
1113
ZERO_PERCENT_STATE,
1214
} from './mock/peer-progress-indicator.mock';
1315

16+
interface PeerProgressIndicatorResponse {
17+
task_definition_id: number;
18+
unit_id: number;
19+
target_grade: number | null;
20+
submitted_percentage: number | null;
21+
is_suppressed: boolean;
22+
is_stale: boolean;
23+
is_feature_enabled: boolean;
24+
last_updated_at: string | null;
25+
unavailable_message: string;
26+
}
27+
1428
@Injectable({
1529
providedIn: 'root',
1630
})
1731
export class PeerProgressIndicatorService {
18-
constructor() {}
19-
20-
// At the moment, it returns mock PPI data for the given state. In future, this will call the real backend API.
32+
constructor(private httpClient: HttpClient) {}
2133

22-
getIndicator(
23-
taskDefinitionId: number,
24-
unitId: number,
25-
targetGrade: number,
26-
state: 'normal' | 'zero' | 'suppressed' | 'unavailable' | 'stale' | 'disabled',
27-
): Observable<PeerProgressIndicator> {
28-
const base = this.resolveState(state);
34+
getIndicator(projectId: number, taskDefinitionId: number): Observable<PeerProgressIndicator> {
35+
const url = `${API_URL}/projects/${projectId}/task_def_id/${taskDefinitionId}/peer_progress`;
2936

30-
return of({
31-
...base,
32-
taskDefinitionId,
33-
unitId,
34-
targetGrade,
35-
});
37+
return this.httpClient.get<PeerProgressIndicatorResponse>(url).pipe(
38+
map((response) => ({
39+
taskDefinitionId: response.task_definition_id,
40+
unitId: response.unit_id,
41+
targetGrade: response.target_grade,
42+
submittedPercentage: response.submitted_percentage,
43+
isSuppressed: response.is_suppressed,
44+
isStale: response.is_stale,
45+
isFeatureEnabled: response.is_feature_enabled,
46+
lastUpdatedAt: response.last_updated_at,
47+
unavailableMessage: response.unavailable_message,
48+
})),
49+
);
3650
}
3751

3852
/**

‎src/app/api/services/spec/peer-progress-indicator.service.spec.ts‎

Lines changed: 60 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,93 @@
1-
import {beforeEach, describe, expect, it} from 'vitest';
1+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
2+
import {provideHttpClient, withInterceptorsFromDi, withXhr} from '@angular/common/http';
3+
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
24
import {TestBed} from '@angular/core/testing';
3-
import {
4-
DISABLED_STATE,
5-
NORMAL_STATE,
6-
STALE_STATE,
7-
SUPPRESSED_STATE,
8-
UNAVAILABLE_STATE,
9-
ZERO_PERCENT_STATE,
10-
} from '../mock/peer-progress-indicator.mock';
5+
import {PeerProgressIndicator} from 'src/app/api/models/peer-progress-indicator';
6+
import API_URL from 'src/app/config/constants/apiUrl';
7+
import {DISABLED_STATE, NORMAL_STATE, SUPPRESSED_STATE} from '../mock/peer-progress-indicator.mock';
118
import {PeerProgressIndicatorService} from '../peer-progress-indicator.service';
129

1310
describe('PeerProgressIndicatorService', () => {
1411
let service: PeerProgressIndicatorService;
12+
let httpMock: HttpTestingController;
1513

1614
beforeEach(() => {
1715
TestBed.configureTestingModule({
18-
providers: [PeerProgressIndicatorService],
16+
providers: [
17+
PeerProgressIndicatorService,
18+
provideHttpClient(withXhr(), withInterceptorsFromDi()),
19+
provideHttpClientTesting(),
20+
],
1921
});
2022

2123
service = TestBed.inject(PeerProgressIndicatorService);
24+
httpMock = TestBed.inject(HttpTestingController);
2225
});
2326

24-
it('should return normal state', () => {
25-
service.getIndicator(1, 10, 2, 'normal').subscribe((result) => {
26-
expect(result.submittedPercentage).toBe(NORMAL_STATE.submittedPercentage);
27-
expect(result.isSuppressed).toBe(NORMAL_STATE.isSuppressed);
28-
expect(result.isFeatureEnabled).toBe(NORMAL_STATE.isFeatureEnabled);
29-
30-
// overwritten fields
31-
expect(result.taskDefinitionId).toBe(1);
32-
expect(result.unitId).toBe(10);
33-
expect(result.targetGrade).toBe(2);
34-
});
27+
afterEach(() => {
28+
httpMock.verify({ignoreCancelled: true});
3529
});
3630

37-
it('should return zero percent state', () => {
38-
service.getIndicator(2, 20, 1, 'zero').subscribe((result) => {
39-
expect(result.submittedPercentage).toBe(ZERO_PERCENT_STATE.submittedPercentage);
40-
expect(result.isSuppressed).toBe(ZERO_PERCENT_STATE.isSuppressed);
31+
it('requests the authorised project/task route without client-supplied cohort parameters', () => {
32+
service.getIndicator(7, 99).subscribe();
4133

42-
expect(result.taskDefinitionId).toBe(2);
43-
expect(result.unitId).toBe(20);
44-
expect(result.targetGrade).toBe(1);
45-
});
46-
});
34+
const request = httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`);
4735

48-
it('should return suppressed state', () => {
49-
service.getIndicator(3, 30, 0, 'suppressed').subscribe((result) => {
50-
expect(result.submittedPercentage).toBe(SUPPRESSED_STATE.submittedPercentage);
51-
expect(result.isSuppressed).toBe(true);
52-
expect(result.unavailableMessage).toBe(SUPPRESSED_STATE.unavailableMessage);
36+
expect(request.request.method).toBe('GET');
37+
expect(request.request.params.keys()).toEqual([]);
5338

54-
expect(result.taskDefinitionId).toBe(3);
55-
expect(result.unitId).toBe(30);
56-
expect(result.targetGrade).toBe(0);
39+
request.flush({
40+
task_definition_id: 99,
41+
unit_id: 1,
42+
target_grade: 2,
43+
submitted_percentage: 40,
44+
is_suppressed: false,
45+
is_stale: false,
46+
is_feature_enabled: true,
47+
last_updated_at: '2026-08-23T00:00:00Z',
48+
unavailable_message: '',
5749
});
5850
});
5951

60-
it('should return unavailable state', () => {
61-
service.getIndicator(4, 40, 3, 'unavailable').subscribe((result) => {
62-
expect(result.submittedPercentage).toBe(UNAVAILABLE_STATE.submittedPercentage);
63-
expect(result.unavailableMessage).toBe(UNAVAILABLE_STATE.unavailableMessage);
52+
it('maps the complete snake-case response and preserves nullable fields', () => {
53+
let result: PeerProgressIndicator | undefined;
6454

65-
expect(result.taskDefinitionId).toBe(4);
66-
expect(result.unitId).toBe(40);
67-
expect(result.targetGrade).toBe(3);
55+
service.getIndicator(7, 99).subscribe((value) => {
56+
result = value;
6857
});
69-
});
7058

71-
it('should return stale state', () => {
72-
service.getIndicator(5, 50, 2, 'stale').subscribe((result) => {
73-
expect(result.submittedPercentage).toBe(STALE_STATE.submittedPercentage);
74-
expect(result.isStale).toBe(true);
75-
expect(result.unavailableMessage).toBe(STALE_STATE.unavailableMessage);
76-
77-
expect(result.taskDefinitionId).toBe(5);
78-
expect(result.unitId).toBe(50);
79-
expect(result.targetGrade).toBe(2);
59+
httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({
60+
task_definition_id: 99,
61+
unit_id: 1,
62+
target_grade: null,
63+
submitted_percentage: null,
64+
is_suppressed: false,
65+
is_stale: false,
66+
is_feature_enabled: true,
67+
last_updated_at: null,
68+
unavailable_message: 'Peer progress is currently unavailable.',
8069
});
81-
});
82-
83-
it('should return disabled state', () => {
84-
service.getIndicator(6, 60, 1, 'disabled').subscribe((result) => {
85-
expect(result.submittedPercentage).toBe(DISABLED_STATE.submittedPercentage);
86-
expect(result.isFeatureEnabled).toBe(false);
87-
expect(result.unavailableMessage).toBe(DISABLED_STATE.unavailableMessage);
8870

89-
expect(result.taskDefinitionId).toBe(6);
90-
expect(result.unitId).toBe(60);
91-
expect(result.targetGrade).toBe(1);
71+
expect(result).toEqual({
72+
taskDefinitionId: 99,
73+
unitId: 1,
74+
targetGrade: null,
75+
submittedPercentage: null,
76+
isSuppressed: false,
77+
isStale: false,
78+
isFeatureEnabled: true,
79+
lastUpdatedAt: null,
80+
unavailableMessage: 'Peer progress is currently unavailable.',
9281
});
9382
});
9483

95-
it('should fall back to unavailable state for unknown keys', () => {
96-
service.getIndicator(99, 99, 0, 'unknown' as 'normal').subscribe((result) => {
97-
expect(result.unavailableMessage).toBe(UNAVAILABLE_STATE.unavailableMessage);
98-
expect(result.submittedPercentage).toBeNull();
84+
it('cancels the live HTTP request when the caller unsubscribes', () => {
85+
const subscription = service.getIndicator(7, 99).subscribe();
86+
const request = httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`);
9987

100-
expect(result.taskDefinitionId).toBe(99);
101-
expect(result.unitId).toBe(99);
102-
expect(result.targetGrade).toBe(0);
103-
});
88+
subscription.unsubscribe();
89+
90+
expect(request.cancelled).toBe(true);
10491
});
10592

10693
describe('getMockUnitSummary', () => {

‎src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
@case ('no-data') {
3434
<div class="ppi-widget ppi-widget--muted">
3535
<mat-icon aria-hidden="false" aria-label="No data icon">hourglass_empty</mat-icon>
36-
<span>No peer submissions yet</span>
36+
<span>Peer submission progress rounds to 0% for your target grade</span>
3737
</div>
3838
}
3939
@case ('stale') {

0 commit comments

Comments
 (0)