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 @@ -404,4 +405,9 @@ 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[]> {
return await this.onlineInvoke<BookProgress[]>('getProjectProgress', { projectId });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@
[disabled]="readonly"
(change)="onChipListChange(book)"
>
@if (!basicMode) {
@if (book.progress != null && basicMode === false) {
<mat-chip-option
[value]="book"
[selected]="book.selected"
[matTooltip]="t('book_progress', { percent: getPercentage(book) | l10nPercent })"
[matTooltip]="t('book_progress', { percent: normalizeRatioForDisplay(book.progress) | l10nPercent })"
>
{{ "canon.book_names." + book.bookId | transloco }}
<div class="border-fill" [style.width]="book.progressPercentage + '%'"></div>
<div class="border-fill" [style.width]="book.progress * 100 + '%'"></div>
</mat-chip-option>
} @else {
<mat-chip-option [value]="book" [selected]="book.selected">
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 @@ -65,28 +66,28 @@ describe('BookMultiSelectComponent', () => {
it('should initialize book options on ngOnChanges', async () => {
await component.ngOnChanges();
expect(component.bookOptions).toEqual([
{ bookNum: 1, bookId: 'GEN', selected: true, progressPercentage: 0 },
{ bookNum: 2, bookId: 'EXO', selected: false, progressPercentage: 15 },
{ bookNum: 3, bookId: 'LEV', selected: true, progressPercentage: 30 },
{ bookNum: 40, bookId: 'MAT', selected: false, progressPercentage: 45 },
{ bookNum: 42, bookId: 'LUK', selected: false, progressPercentage: 60 },
{ bookNum: 67, bookId: 'TOB', selected: false, progressPercentage: 80 },
{ bookNum: 70, bookId: 'WIS', selected: false, progressPercentage: 100 }
{ bookNum: 1, bookId: 'GEN', selected: true, progress: 0.0 },
{ bookNum: 2, bookId: 'EXO', selected: false, progress: 0.15 },
{ bookNum: 3, bookId: 'LEV', selected: true, progress: 0.3 },
{ bookNum: 40, bookId: 'MAT', selected: false, progress: 0.45 },
{ bookNum: 42, bookId: 'LUK', selected: false, progress: 0.6 },
{ bookNum: 67, bookId: 'TOB', selected: false, progress: 0.8 },
{ bookNum: 70, bookId: 'WIS', selected: false, progress: 1 }
]);
});

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, progress: null },
{ bookNum: 2, bookId: 'EXO', selected: false, progress: null },
{ bookNum: 3, bookId: 'LEV', selected: true, progress: null },
{ bookNum: 40, bookId: 'MAT', selected: false, progress: null },
{ bookNum: 42, bookId: 'LUK', selected: false, progress: null },
{ bookNum: 67, bookId: 'TOB', selected: false, progress: null },
{ bookNum: 70, bookId: 'WIS', selected: false, progress: 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 ratio between 0 and 1, or null if not available. */
progress: 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 === false) {
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 progressByBookNum = (progress?.books ?? []).map(b => ({
bookNum: Canon.bookIdToNumber(b.bookId),
progress: estimatedActualBookProgress(b)
}));

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
progress: progressByBookNum.find(p => p.bookNum === book.number)?.progress ?? null
}));

this.booksOT = this.selectedBooks.filter(n => Canon.isBookOT(n.number));
Expand Down Expand Up @@ -136,8 +149,11 @@ 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 1, and if it's between 0.99 and 1, returns 0.99 to prevent showing a book as 100%
* complete when it's not.
*/
normalizeRatioForDisplay(ratio: number): number {
return ratio > 0.99 && ratio < 1 ? 0.99 : ratio;
}
}
Loading
Loading