Skip to content

Commit ca0a6da

Browse files
committed
SF-3657 Move project progress calculation to back end
1 parent 9ccc759 commit ca0a6da

File tree

17 files changed

+620
-531
lines changed

17 files changed

+620
-531
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { QueryParameters, QueryResults } from 'xforge-common/query-parameters';
2424
import { RealtimeService } from 'xforge-common/realtime.service';
2525
import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service';
2626
import { EventMetric } from '../event-metrics/event-metric';
27+
import { BookProgress } from '../shared/progress-service/progress.service';
2728
import { booksFromScriptureRange } from '../shared/utils';
2829
import { BiblicalTermDoc } from './models/biblical-term-doc';
2930
import { InviteeStatus } from './models/invitee-status';
@@ -404,4 +405,9 @@ export class SFProjectService extends ProjectService<SFProject, SFProjectDoc> {
404405

405406
return await this.onlineInvoke<QueryResults<EventMetric>>('eventMetrics', params);
406407
}
408+
409+
/** Gets project progress by calling the backend aggregation endpoint. */
410+
async getProjectProgress(projectId: string): Promise<BookProgress[]> {
411+
return await this.onlineInvoke<BookProgress[]>('getProjectProgress', { projectId });
412+
}
407413
}

src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@
4949
[disabled]="readonly"
5050
(change)="onChipListChange(book)"
5151
>
52-
@if (!basicMode) {
52+
@if (book.progress != null && basicMode === false) {
5353
<mat-chip-option
5454
[value]="book"
5555
[selected]="book.selected"
56-
[matTooltip]="t('book_progress', { percent: getPercentage(book) | l10nPercent })"
56+
[matTooltip]="t('book_progress', { percent: normalizeRatioToPercent(book.progress) | l10nPercent })"
5757
>
5858
{{ "canon.book_names." + book.bookId | transloco }}
59-
<div class="border-fill" [style.width]="book.progressPercentage + '%'"></div>
59+
<div class="border-fill" [style.width]="book.progress + '%'"></div>
6060
</mat-chip-option>
6161
} @else {
6262
<mat-chip-option [value]="book" [selected]="book.selected">

src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
2-
import { of } from 'rxjs';
3-
import { mock, when } from 'ts-mockito';
2+
import { anything, mock, when } from 'ts-mockito';
43
import { I18nService } from 'xforge-common/i18n.service';
54
import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils';
6-
import { ProgressService, TextProgress } from '../progress-service/progress.service';
5+
import { ProgressService, ProjectProgress } from '../progress-service/progress.service';
76
import { Book } from './book-multi-select';
87
import { BookMultiSelectComponent } from './book-multi-select.component';
98

@@ -35,22 +34,24 @@ describe('BookMultiSelectComponent', () => {
3534
{ number: 1, selected: true },
3635
{ number: 3, selected: true }
3736
];
38-
when(mockedProgressService.isLoaded$).thenReturn(of(true));
39-
when(mockedProgressService.texts).thenReturn([
40-
{ text: { bookNum: 1 }, percentage: 0 } as TextProgress,
41-
{ text: { bookNum: 2 }, percentage: 15 } as TextProgress,
42-
{ text: { bookNum: 3 }, percentage: 30 } as TextProgress,
43-
{ text: { bookNum: 40 }, percentage: 45 } as TextProgress,
44-
{ text: { bookNum: 42 }, percentage: 60 } as TextProgress,
45-
{ text: { bookNum: 67 }, percentage: 80 } as TextProgress,
46-
{ text: { bookNum: 70 }, percentage: 100 } as TextProgress
47-
]);
37+
when(mockedProgressService.getProgress(anything(), anything())).thenResolve(
38+
new ProjectProgress([
39+
{ bookId: 'GEN', verseSegments: 0, blankVerseSegments: 0 },
40+
{ bookId: 'EXO', verseSegments: 10_000, blankVerseSegments: 8_500 },
41+
{ bookId: 'LEV', verseSegments: 10_000, blankVerseSegments: 7_000 },
42+
{ bookId: 'MAT', verseSegments: 10_000, blankVerseSegments: 5_500 },
43+
{ bookId: 'LUK', verseSegments: 10_000, blankVerseSegments: 4_000 },
44+
{ bookId: 'TOB', verseSegments: 10_000, blankVerseSegments: 2_000 },
45+
{ bookId: 'WIS', verseSegments: 10_000, blankVerseSegments: 0 }
46+
])
47+
);
4848
when(mockedI18nService.localeCode).thenReturn('en');
4949

5050
fixture = TestBed.createComponent(BookMultiSelectComponent);
5151
component = fixture.componentInstance;
5252
component.availableBooks = mockBooks;
5353
component.selectedBooks = mockSelectedBooks;
54+
component.projectId = 'test-project-id';
5455
fixture.detectChanges();
5556
});
5657

@@ -76,17 +77,17 @@ describe('BookMultiSelectComponent', () => {
7677
});
7778

7879
it('should not crash when texts have not yet loaded', async () => {
79-
when(mockedProgressService.texts).thenReturn([]);
80+
when(mockedProgressService.getProgress(anything(), anything())).thenResolve(new ProjectProgress([]));
8081
await component.ngOnChanges();
8182

8283
expect(component.bookOptions).toEqual([
83-
{ bookNum: 1, bookId: 'GEN', selected: true, progressPercentage: 0 },
84-
{ bookNum: 2, bookId: 'EXO', selected: false, progressPercentage: 0 },
85-
{ bookNum: 3, bookId: 'LEV', selected: true, progressPercentage: 0 },
86-
{ bookNum: 40, bookId: 'MAT', selected: false, progressPercentage: 0 },
87-
{ bookNum: 42, bookId: 'LUK', selected: false, progressPercentage: 0 },
88-
{ bookNum: 67, bookId: 'TOB', selected: false, progressPercentage: 0 },
89-
{ bookNum: 70, bookId: 'WIS', selected: false, progressPercentage: 0 }
84+
{ bookNum: 1, bookId: 'GEN', selected: true, progressPercentage: null },
85+
{ bookNum: 2, bookId: 'EXO', selected: false, progressPercentage: null },
86+
{ bookNum: 3, bookId: 'LEV', selected: true, progressPercentage: null },
87+
{ bookNum: 40, bookId: 'MAT', selected: false, progressPercentage: null },
88+
{ bookNum: 42, bookId: 'LUK', selected: false, progressPercentage: null },
89+
{ bookNum: 67, bookId: 'TOB', selected: false, progressPercentage: null },
90+
{ bookNum: 70, bookId: 'WIS', selected: false, progressPercentage: null }
9091
]);
9192
});
9293

src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
55
import { MatTooltip } from '@angular/material/tooltip';
66
import { TranslocoModule } from '@ngneat/transloco';
77
import { Canon } from '@sillsdev/scripture';
8-
import { filter, firstValueFrom } from 'rxjs';
98
import { L10nPercentPipe } from 'xforge-common/l10n-percent.pipe';
10-
import { ProgressService } from '../progress-service/progress.service';
9+
import { estimatedActualBookProgress, ProgressService, ProjectProgress } from '../progress-service/progress.service';
1110
import { Book } from './book-multi-select';
1211

1312
export interface BookOption {
1413
bookNum: number;
1514
bookId: string;
1615
selected: boolean;
17-
progressPercentage: number;
16+
/** The progress of the book as a ratio between 0 and 1, or null if not available. */
17+
progress: number | null;
1818
}
1919

2020
type Scope = 'OT' | 'NT' | 'DC';
@@ -37,6 +37,8 @@ export class BookMultiSelectComponent implements OnChanges {
3737
@Input() availableBooks: Book[] = [];
3838
@Input() selectedBooks: Book[] = [];
3939
@Input() readonly: boolean = false;
40+
/** The ID of the project to get the progress. */
41+
@Input() projectId?: string;
4042
@Input() projectName?: string;
4143
@Input() basicMode: boolean = false;
4244
@Output() bookSelect = new EventEmitter<number[]>();
@@ -66,15 +68,26 @@ export class BookMultiSelectComponent implements OnChanges {
6668
}
6769

6870
async initBookOptions(): Promise<void> {
69-
await firstValueFrom(this.progressService.isLoaded$.pipe(filter(loaded => loaded)));
71+
// Only load progress if not in basic mode
72+
let progress: ProjectProgress | undefined;
73+
if (this.basicMode === false) {
74+
if (this.projectId == null) {
75+
throw new Error('app-book-multi-select requires a projectId input to initialize when not in basic mode');
76+
}
77+
progress = await this.progressService.getProgress(this.projectId, { maxStalenessMs: 30_000 });
78+
}
7079
this.loaded = true;
71-
const progress = this.progressService.texts;
80+
81+
const progressByBookNum = (progress?.books ?? []).map(b => ({
82+
bookNum: Canon.bookIdToNumber(b.bookId),
83+
progress: estimatedActualBookProgress(b)
84+
}));
7285

7386
this.bookOptions = this.availableBooks.map((book: Book) => ({
7487
bookNum: book.number,
7588
bookId: Canon.bookNumberToId(book.number),
7689
selected: this.selectedBooks.find(b => book.number === b.number) !== undefined,
77-
progressPercentage: progress.find(p => p.text.bookNum === book.number)?.percentage ?? 0
90+
progress: progressByBookNum.find(p => p.bookNum === book.number)?.progress ?? null
7891
}));
7992

8093
this.booksOT = this.selectedBooks.filter(n => Canon.isBookOT(n.number));
@@ -136,8 +149,12 @@ export class BookMultiSelectComponent implements OnChanges {
136149
return this.availableBooks.findIndex(n => Canon.isBookDC(n.number)) > -1;
137150
}
138151

139-
getPercentage(book: BookOption): number {
140-
// avoid showing 100% when it's not quite there
141-
return (book.progressPercentage > 99 && book.progressPercentage < 100 ? 99 : book.progressPercentage) / 100;
152+
/**
153+
* Takes a number between 0 and 1, and converts it to a percentage between 0 and 100. Any percentage between 99 and
154+
* 100 is floored to 99
155+
*/
156+
normalizeRatioToPercent(ratio: number): number {
157+
const percentage = ratio * 100;
158+
return percentage > 99 && percentage < 100 ? 99 : percentage;
142159
}
143160
}

0 commit comments

Comments
 (0)