Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { QueryParameters, QueryResults } from 'xforge-common/query-parameters';
import { RealtimeService } from 'xforge-common/realtime.service';
import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service';
import { EventMetric } from '../event-metrics/event-metric';
import { BookProgress } from '../shared/progress-service/progress.service';
import { booksFromScriptureRange } from '../shared/utils';
import { BiblicalTermDoc } from './models/biblical-term-doc';
import { InviteeStatus } from './models/invitee-status';
Expand Down Expand Up @@ -410,4 +411,10 @@ export class SFProjectService extends ProjectService<SFProject, SFProjectDoc> {

return await this.onlineInvoke<QueryResults<EventMetric>>('eventMetrics', params);
}

/** Gets project progress by calling the backend aggregation endpoint. */
async getProjectProgress(projectId: string): Promise<BookProgress[]> {
// TODO remove non-null assertion after #3622 is merged
return (await this.onlineInvoke<BookProgress[]>('getProjectProgress', { projectId }))!;
Comment on lines +416 to +418
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment references issue #3622, but the non-null assertion operator is being used without checking if this will actually be resolved by that issue. The backend method GetProjectProgress returns BookProgress[] directly, not a nullable type, so the non-null assertion may not be needed at all. Verify if this TODO is accurate and if the assertion is actually necessary.

Suggested change
async getProjectProgress(projectId: string): Promise<BookProgress[]> {
// TODO remove non-null assertion after #3622 is merged
return (await this.onlineInvoke<BookProgress[]>('getProjectProgress', { projectId }))!;
async getProjectProgress(projectId: string): Promise<BookProgress[] | undefined> {
// TODO remove non-null assertion after #3622 is merged
return await this.onlineInvoke<BookProgress[]>('getProjectProgress', { projectId });

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@
[disabled]="readonly"
(change)="onChipListChange(book)"
>
@if (!basicMode) {
@if (book.progressPercentage != null && basicMode === false) {
<mat-chip-option
[value]="book"
[selected]="book.selected"
[matTooltip]="t('book_progress', { percent: getPercentage(book) | l10nPercent })"
[matTooltip]="t('book_progress', { percent: normalizePercentage(book.progressPercentage) | l10nPercent })"
>
{{ "canon.book_names." + book.bookId | transloco }}
<div class="border-fill" [style.width]="book.progressPercentage + '%'"></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { mock, when } from 'ts-mockito';
import { anything, mock, when } from 'ts-mockito';
import { I18nService } from 'xforge-common/i18n.service';
import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils';
import { ProgressService, TextProgress } from '../progress-service/progress.service';
import { ProgressService, ProjectProgress } from '../progress-service/progress.service';
import { Book } from './book-multi-select';
import { BookMultiSelectComponent } from './book-multi-select.component';

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

fixture = TestBed.createComponent(BookMultiSelectComponent);
component = fixture.componentInstance;
component.availableBooks = mockBooks;
component.selectedBooks = mockSelectedBooks;
component.projectId = 'test-project-id';
fixture.detectChanges();
});

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

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

expect(component.bookOptions).toEqual([
{ bookNum: 1, bookId: 'GEN', selected: true, progressPercentage: 0 },
{ bookNum: 2, bookId: 'EXO', selected: false, progressPercentage: 0 },
{ bookNum: 3, bookId: 'LEV', selected: true, progressPercentage: 0 },
{ bookNum: 40, bookId: 'MAT', selected: false, progressPercentage: 0 },
{ bookNum: 42, bookId: 'LUK', selected: false, progressPercentage: 0 },
{ bookNum: 67, bookId: 'TOB', selected: false, progressPercentage: 0 },
{ bookNum: 70, bookId: 'WIS', selected: false, progressPercentage: 0 }
{ bookNum: 1, bookId: 'GEN', selected: true, progressPercentage: null },
{ bookNum: 2, bookId: 'EXO', selected: false, progressPercentage: null },
{ bookNum: 3, bookId: 'LEV', selected: true, progressPercentage: null },
{ bookNum: 40, bookId: 'MAT', selected: false, progressPercentage: null },
{ bookNum: 42, bookId: 'LUK', selected: false, progressPercentage: null },
{ bookNum: 67, bookId: 'TOB', selected: false, progressPercentage: null },
{ bookNum: 70, bookId: 'WIS', selected: false, progressPercentage: null }
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslocoModule } from '@ngneat/transloco';
import { Canon } from '@sillsdev/scripture';
import { filter, firstValueFrom } from 'rxjs';
import { L10nPercentPipe } from 'xforge-common/l10n-percent.pipe';
import { ProgressService } from '../progress-service/progress.service';
import { estimatedActualBookProgress, ProgressService, ProjectProgress } from '../progress-service/progress.service';
import { Book } from './book-multi-select';

export interface BookOption {
bookNum: number;
bookId: string;
selected: boolean;
progressPercentage: number;
/** The progress of the book as a percentage between 0 and 100, or null if not available. */
progressPercentage: number | null;
}

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

async initBookOptions(): Promise<void> {
await firstValueFrom(this.progressService.isLoaded$.pipe(filter(loaded => loaded)));
// Only load progress if not in basic mode
let progress: ProjectProgress | undefined;
if (this.basicMode !== true) {
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparison basicMode !== true is used instead of a simple falsy check. However, according to the coding guidelines, you should use explicit comparisons rather than relying on truthy/falsy. Since basicMode is a boolean, consider using basicMode === false instead of basicMode !== true for better clarity about what state you're checking for.

Copilot generated this review using guidance from repository custom instructions.
if (this.projectId == null) {
throw new Error('app-book-multi-select requires a projectId input to initialize when not in basic mode');
}
progress = await this.progressService.getProgress(this.projectId, { maxStalenessMs: 30_000 });
}
this.loaded = true;
const progress = this.progressService.texts;

const progressPercentageByBookNum = (progress?.books ?? []).map(b => ({
bookNum: Canon.bookIdToNumber(b.bookId),
percentage: estimatedActualBookProgress(b) * 100
}));

this.bookOptions = this.availableBooks.map((book: Book) => ({
bookNum: book.number,
bookId: Canon.bookNumberToId(book.number),
selected: this.selectedBooks.find(b => book.number === b.number) !== undefined,
progressPercentage: progress.find(p => p.text.bookNum === book.number)?.percentage ?? 0
progressPercentage: progressPercentageByBookNum.find(p => p.bookNum === book.number)?.percentage ?? null
}));

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

getPercentage(book: BookOption): number {
// avoid showing 100% when it's not quite there
return (book.progressPercentage > 99 && book.progressPercentage < 100 ? 99 : book.progressPercentage) / 100;
/** Takes a number between 0 and 100, and rounds it by flooring any number between 99 and 100 to 99 */
normalizePercentage(percentage: number): number {
return (percentage > 99 && percentage < 100 ? 99 : percentage) / 100;
}
}
Loading
Loading