optimize song usage

This commit is contained in:
2026-03-15 13:19:20 +01:00
parent d907c89eb6
commit ab535d48b9
21 changed files with 312 additions and 29 deletions

View File

@@ -13,7 +13,7 @@
[padding]="false"
heading="Meine Veranstaltungen"
>
@for (show of shows | sortBy: 'desc':'date'; track show) {
@for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) {
<app-list-item
[routerLink]="show.id"
[show]="show"

View File

@@ -0,0 +1,60 @@
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {ShowDataService} from './show-data.service';
import {ShowSongDataService} from './show-song-data.service';
import {ShowSongIndexService} from './show-song-index.service';
import {UserSessionService} from '../../../services/user/user-session.service';
import {User} from '../../../services/user/user';
describe('ShowSongIndexService', () => {
let service: ShowSongIndexService;
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>;
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>;
let sessionSpy: jasmine.SpyObj<UserSessionService>;
beforeEach(async () => {
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['listRaw$', 'update']);
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['list$']);
sessionSpy = jasmine.createSpyObj<UserSessionService>('UserSessionService', ['update$'], {
user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User),
});
showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType<ShowDataService['listRaw$']>);
showDataServiceSpy.update.and.resolveTo();
showSongDataServiceSpy.list$.and.callFake((showId: string) => {
if (showId === 'show-1') {
return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never;
}
return of([{songId: 'song-3'}] as never) as never;
});
await TestBed.configureTestingModule({
providers: [
{provide: ShowDataService, useValue: showDataServiceSpy},
{provide: ShowSongDataService, useValue: showSongDataServiceSpy},
{provide: UserSessionService, useValue: sessionSpy},
],
});
service = TestBed.inject(ShowSongIndexService);
});
it('should rebuild the distinct songIds index for all shows', async () => {
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({
showsProcessed: 2,
showSongsProcessed: 4,
});
expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-1', {songIds: ['song-1', 'song-2']});
expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-2', {songIds: ['song-3']});
});
it('should reject index rebuilds for non-admin users', async () => {
Object.defineProperty(sessionSpy, 'user$', {
value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User),
});
await expectAsync(service.rebuildShowSongIds()).toBeRejectedWithError('Admin role required to rebuild show song ids.');
});
});

View File

@@ -0,0 +1,65 @@
import {Injectable, inject} from '@angular/core';
import {firstValueFrom} from 'rxjs';
import {take} from 'rxjs/operators';
import {ShowDataService} from './show-data.service';
import {ShowSongDataService} from './show-song-data.service';
import {UserSessionService} from '../../../services/user/user-session.service';
export interface ShowSongIndexMigrationResult {
showsProcessed: number;
showSongsProcessed: number;
}
export interface MigrationProgress {
processed: number;
total: number;
showId: string;
showSongsProcessed: number;
}
@Injectable({
providedIn: 'root',
})
export class ShowSongIndexService {
private session = inject(UserSessionService);
private showDataService = inject(ShowDataService);
private showSongDataService = inject(ShowSongDataService);
public async rebuildShowSongIds(onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> {
const currentUser = await firstValueFrom(this.session.user$.pipe(take(1)));
if (!currentUser || !this.hasAdminRole(currentUser.role)) {
throw new Error('Admin role required to rebuild show song ids.');
}
const shows = await firstValueFrom(this.showDataService.listRaw$());
let showSongsProcessed = 0;
let processed = 0;
for (const show of shows) {
const showSongs = await firstValueFrom(this.showSongDataService.list$(show.id));
const songIds = [...new Set(showSongs.map(showSong => showSong.songId).filter(Boolean))];
showSongsProcessed += showSongs.length;
await this.showDataService.update(show.id, {songIds});
processed += 1;
onProgress?.({
processed,
total: shows.length,
showId: show.id,
showSongsProcessed,
});
}
return {
showsProcessed: shows.length,
showSongsProcessed,
};
}
private hasAdminRole(role: string | null | undefined): boolean {
if (!role) {
return false;
}
return role.split(';').includes('admin');
}
}

View File

@@ -22,7 +22,7 @@ describe('ShowSongService', () => {
const show = {id: 'show-1', order: ['show-song-1', 'show-song-2']} as unknown as Show;
beforeEach(async () => {
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}});
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}});
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']);
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']);
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
@@ -66,7 +66,7 @@ describe('ShowSongService', () => {
songId: 'song-1',
key: 'G',
keyOriginal: 'G',
chordMode: 'letters',
chordMode: 'onlyFirst',
addedLive: true,
});
});
@@ -103,7 +103,7 @@ describe('ShowSongService', () => {
await service.delete$('show-1', 'show-song-1', 0);
expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1');
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {order: ['show-song-2']});
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', jasmine.objectContaining({order: ['show-song-2']}));
expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1');
});

View File

@@ -5,6 +5,7 @@ import {ShowSong} from './show-song';
import {SongDataService} from '../../songs/services/song-data.service';
import {UserService} from '../../../services/user/user.service';
import {ShowService} from './show.service';
import {arrayRemove, arrayUnion} from '@angular/fire/firestore';
@Injectable({
providedIn: 'root',
@@ -27,8 +28,9 @@ export class ShowSongService {
chordMode: user.chordMode,
addedLive,
};
await this.userService.incSongCount(songId);
return await this.showSongDataService.add(showId, data);
const showSongId = await this.showSongDataService.add(showId, data);
await Promise.all([this.userService.incSongCount(songId), this.showService.update$(showId, {songIds: arrayUnion(songId) as never})]);
return showSongId;
}
public read$ = (showId: string, songId: string): Observable<ShowSong | null> => this.showSongDataService.read$(showId, songId);
@@ -38,14 +40,19 @@ export class ShowSongService {
public list = (showId: string): Promise<ShowSong[]> => firstValueFrom(this.list$(showId));
public async delete$(showId: string, showSongId: string, index: number): Promise<void> {
const [showSong, show] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId))]);
const [showSong, show, showSongs] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId)), this.list(showId)]);
if (!show) return;
if (!showSong) return;
const order = [...show.order];
order.splice(index, 1);
const hasSameSongStillInShow = showSongs.some(song => song.id !== showSongId && song.songId === showSong.songId);
await Promise.all([this.showSongDataService.delete(showId, showSongId), this.showService.update$(showId, {order}), this.userService.decSongCount(showSong.songId)]);
await Promise.all([
this.showSongDataService.delete(showId, showSongId),
this.showService.update$(showId, hasSameSongStillInShow ? {order} : {order, songIds: arrayRemove(showSong.songId) as never}),
this.userService.decSongCount(showSong.songId),
]);
}
public update$ = async (showId: string, songId: string, data: Partial<ShowSong>): Promise<void> => await this.showSongDataService.update$(showId, songId, data);

View File

@@ -16,9 +16,11 @@ describe('ShowService', () => {
beforeEach(async () => {
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], {
list$: of(shows),
});
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>(
'ShowDataService',
['read$', 'listPublicSince$', 'update', 'add'],
{list$: of(shows) as unknown as ShowDataService['list$']}
);
showDataServiceSpy.read$.and.returnValue(of(shows[0]));
showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]]));
showDataServiceSpy.update.and.resolveTo();
@@ -97,6 +99,7 @@ describe('ShowService', () => {
showType: type,
owner: 'user-1',
order: [],
songIds: [],
public: true,
});
});
@@ -111,6 +114,7 @@ describe('ShowService', () => {
showType: type,
owner: 'user-1',
order: [],
songIds: [],
public: false,
});
});

View File

@@ -39,6 +39,7 @@ export class ShowService {
...data,
owner: user.id,
order: [],
songIds: [],
public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1,
};
return await this.showDataService.add(calculatedData);

View File

@@ -7,6 +7,7 @@ export interface Show {
showType: string;
date: Timestamp;
owner: string;
songIds?: string[];
public: boolean;
reported: boolean;
published: boolean;

View File

@@ -49,6 +49,7 @@ import {OwnerDirective} from '../../../services/user/owner.directive';
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
import {MatMenu, MatMenuTrigger} from '@angular/material/menu';
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {UserService} from '../../../services/user/user.service';
@Component({
selector: 'app-show',
@@ -88,6 +89,7 @@ export class ShowComponent implements OnInit, OnDestroy {
private docxService = inject(DocxService);
private router = inject(Router);
private cRef = inject(ChangeDetectorRef);
private userService = inject(UserService);
public dialog = inject(MatDialog);
private guestShowService = inject(GuestShowService);
@@ -171,14 +173,14 @@ export class ShowComponent implements OnInit, OnDestroy {
}
public onArchive(archived: boolean): void {
if (!archived && this.showId != null) void this.showService.update$(this.showId, {archived});
if (!archived && this.showId != null) void this.setArchiveState(false);
else {
const dialogRef = this.dialog.open(ArchiveDialogComponent, {
width: '350px',
});
dialogRef.afterClosed().pipe(take(1)).subscribe((archive: boolean) => {
if (archive && this.showId != null) void this.showService.update$(this.showId, {archived});
if (archive && this.showId != null) void this.setArchiveState(true);
});
}
}
@@ -274,6 +276,20 @@ export class ShowComponent implements OnInit, OnDestroy {
const widthInCh = Math.max(3, longestLabelLength);
return `${widthInCh}ch`;
}
private async setArchiveState(archived: boolean): Promise<void> {
if (!this.showId) {
return;
}
const updates: Array<Promise<void | null>> = [this.showService.update$(this.showId, {archived})];
(this.showSongs ?? []).forEach(showSong => {
updates.push(archived ? this.userService.decSongCount(showSong.songId) : this.userService.incSongCount(showSong.songId));
});
await Promise.all(updates);
}
}
export interface Swiper {

View File

@@ -40,7 +40,12 @@
@if (song.origin) {
<div>Quelle: {{ song.origin }}</div>
}
<div>Wie oft verwendet: {{ songCount$ | async }}</div>
<div
[matTooltip]="songUsageTooltip$ | async"
matTooltipPosition="above"
>
Wie oft verwendet: {{ songCount$ | async }}
</div>
</div>
</div>
@if (user$ | async; as user) {
@@ -81,7 +86,7 @@
Zu Veranstaltung hinzufügen
</app-button>
<mat-menu #menu="matMenu">
@for (show of privateShows$|async; track show) {
@for (show of privateShows$|async; track show.id) {
<app-button (click)="addSongToShow(show, song)">
{{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }}
</app-button>

View File

@@ -3,6 +3,11 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {SongComponent} from './song.component';
import {of} from 'rxjs';
import {ActivatedRoute} from '@angular/router';
import {SongService} from '../services/song.service';
import {FileDataService} from '../services/file-data.service';
import {UserService} from '../../../services/user/user.service';
import {ShowService} from '../../shows/services/show.service';
import {ShowSongService} from '../../shows/services/show-song.service';
describe('SongComponent', () => {
let component: SongComponent;
@@ -13,9 +18,28 @@ describe('SongComponent', () => {
};
beforeEach(waitForAsync(() => {
const songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['read$']);
const fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['read$']);
const userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}),
});
const showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']);
const showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('ShowSongService', ['new$']);
songServiceSpy.read$.and.returnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never));
fileDataServiceSpy.read$.and.returnValue(of([]));
showServiceSpy.list$.and.returnValue(of([]));
void TestBed.configureTestingModule({
imports: [SongComponent],
providers: [{provide: ActivatedRoute, useValue: mockActivatedRoute}],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: SongService, useValue: songServiceSpy},
{provide: FileDataService, useValue: fileDataServiceSpy},
{provide: UserService, useValue: userServiceSpy},
{provide: ShowService, useValue: showServiceSpy},
{provide: ShowSongService, useValue: showSongServiceSpy},
],
}).compileComponents();
}));

View File

@@ -25,6 +25,7 @@ import {SongTypePipe} from '../../../widget-modules/pipes/song-type-translater/s
import {LegalOwnerPipe} from '../../../widget-modules/pipes/legal-owner-translator/legal-owner.pipe';
import {StatusPipe} from '../../../widget-modules/pipes/status-translater/status.pipe';
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {MatTooltip} from '@angular/material/tooltip';
@Component({
selector: 'app-song',
@@ -48,6 +49,7 @@ import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/s
LegalOwnerPipe,
StatusPipe,
ShowTypePipe,
MatTooltip,
],
})
export class SongComponent implements OnInit {
@@ -63,10 +65,14 @@ export class SongComponent implements OnInit {
public files$: Observable<File[] | null> | null = null;
public user$: Observable<User | null> | null = null;
public songCount$: Observable<number> | null = null;
public songUsageShows$: Observable<Show[]> | null = null;
public songUsageTooltip$: Observable<string> | null = null;
public faEdit = faEdit;
public faDelete = faTrash;
public faFileCirclePlus = faFileCirclePlus;
public privateShows$ = this.showService.list$().pipe(map(show => show.filter(_ => !_.published).sort((a, b) => b.date.toMillis() - a.date.toMillis())));
private dateFormatter = new Intl.DateTimeFormat('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
private showTypePipe = new ShowTypePipe();
public constructor() {
const userService = this.userService;
@@ -98,6 +104,33 @@ export class SongComponent implements OnInit {
}),
distinctUntilChanged()
);
this.songUsageShows$ = combineLatest([this.userService.user$, this.showService.list$(), song$]).pipe(
map(([user, shows, song]) => {
if (!user || !song) {
return [];
}
return shows
.filter(show => show.owner === user.id)
.filter(show => (show.songIds ?? []).includes(song.id))
.sort((a, b) => b.date.toMillis() - a.date.toMillis());
})
);
this.songUsageTooltip$ = combineLatest([this.songCount$, this.songUsageShows$]).pipe(
map(([count, shows]) => {
if (count === 0) {
return 'Noch in keiner Show verwendet.';
}
if (shows.length === 0) {
return 'Verwendungen vorhanden, aber Show-Zuordnung noch nicht indexiert.';
}
return shows.map(show => `${this.dateFormatter.format(show.date.toDate())} - ${this.showTypePipe.transform(show.showType)}`).join('\n');
})
);
}
public getFlags = (flags: string): string[] => {

View File

@@ -30,7 +30,7 @@ export class DbCollection<T> {
) {}
public add(data: Partial<T>): Promise<DocumentReference<T>> {
return addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>);
return runInInjectionContext(this.environmentInjector, () => addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>));
}
public valueChanges(options?: {idField?: string}): Observable<T[]> {
@@ -38,7 +38,7 @@ export class DbCollection<T> {
}
private get ref(): CollectionReference<DocumentData> {
return collection(this.fs, this.path);
return runInInjectionContext(this.environmentInjector, () => collection(this.fs, this.path));
}
}
@@ -50,15 +50,15 @@ export class DbDocument<T> {
) {}
public set(data: Partial<T>): Promise<void> {
return setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>);
return runInInjectionContext(this.environmentInjector, () => setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>));
}
public update(data: Partial<T>): Promise<void> {
return updateDoc(this.ref, data as Partial<DocumentData>);
return runInInjectionContext(this.environmentInjector, () => updateDoc(this.ref, data as Partial<DocumentData>));
}
public delete(): Promise<void> {
return deleteDoc(this.ref);
return runInInjectionContext(this.environmentInjector, () => deleteDoc(this.ref));
}
public collection<U>(subPath: string): DbCollection<U> {
@@ -73,7 +73,7 @@ export class DbDocument<T> {
}
private get ref(): DocumentReference<DocumentData> {
return doc(this.fs, this.path);
return runInInjectionContext(this.environmentInjector, () => doc(this.fs, this.path));
}
}
@@ -103,7 +103,9 @@ export class DbService {
return this.col(ref).valueChanges({idField: 'id'});
}
const q = query(collection(this.fs, ref), ...queryConstraints);
return runInInjectionContext(this.environmentInjector, () => collectionData(q, {idField: 'id'}) as Observable<T[]>);
return runInInjectionContext(this.environmentInjector, () => {
const q = query(collection(this.fs, ref), ...queryConstraints);
return collectionData(q, {idField: 'id'}) as Observable<T[]>;
});
}
}

View File

@@ -25,8 +25,8 @@ describe('UserSongUsageService', () => {
sessionSpy.update$.and.resolveTo();
showDataServiceSpy.listRaw$.and.returnValue(
of([
{id: 'show-1', owner: 'user-1'},
{id: 'show-2', owner: 'user-2'},
{id: 'show-1', owner: 'user-1', archived: false},
{id: 'show-2', owner: 'user-2', archived: true},
] as never)
);
showSongDataServiceSpy.list$.and.callFake((showId: string) =>
@@ -66,11 +66,11 @@ describe('UserSongUsageService', () => {
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({
usersProcessed: 2,
showsProcessed: 2,
showSongsProcessed: 4,
showSongsProcessed: 3,
});
expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {songUsage: {'song-1': 2, 'song-2': 1}});
expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {'song-3': 1}});
expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {}});
});
it('should reject song usage rebuilds for non-admin users', async () => {

View File

@@ -40,6 +40,10 @@ export class UserSongUsageService {
let showSongsProcessed = 0;
for (const show of shows) {
if (show.archived) {
continue;
}
const ownerId = show.owner;
if (!ownerId) {
continue;

View File

@@ -3,11 +3,13 @@ import {of} from 'rxjs';
import {UserService} from './user.service';
import {UserSessionService} from './user-session.service';
import {UserSongUsageService} from './user-song-usage.service';
import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
describe('UserService', () => {
let service: UserService;
let sessionSpy: jasmine.SpyObj<UserSessionService>;
let songUsageSpy: jasmine.SpyObj<UserSongUsageService>;
let showSongIndexSpy: jasmine.SpyObj<ShowSongIndexService>;
beforeEach(async () => {
sessionSpy = jasmine.createSpyObj<UserSessionService>(
@@ -20,6 +22,7 @@ describe('UserService', () => {
}
);
songUsageSpy = jasmine.createSpyObj<UserSongUsageService>('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']);
showSongIndexSpy = jasmine.createSpyObj<ShowSongIndexService>('ShowSongIndexService', ['rebuildShowSongIds']);
sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never);
sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never);
@@ -34,11 +37,13 @@ describe('UserService', () => {
songUsageSpy.incSongCount.and.resolveTo();
songUsageSpy.decSongCount.and.resolveTo();
songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
showSongIndexSpy.rebuildShowSongIds.and.resolveTo({showsProcessed: 2, showSongsProcessed: 3});
await TestBed.configureTestingModule({
providers: [
{provide: UserSessionService, useValue: sessionSpy},
{provide: UserSongUsageService, useValue: songUsageSpy},
{provide: ShowSongIndexService, useValue: showSongIndexSpy},
],
});
@@ -100,9 +105,11 @@ describe('UserService', () => {
await service.incSongCount('song-1');
await service.decSongCount('song-2');
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({showsProcessed: 2, showSongsProcessed: 3});
expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1');
expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2');
expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled();
expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import {Observable} from 'rxjs';
import {User} from './user';
import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service';
import {UserSessionService} from './user-session.service';
import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
@Injectable({
providedIn: 'root',
@@ -10,6 +11,7 @@ import {UserSessionService} from './user-session.service';
export class UserService {
private session = inject(UserSessionService);
private songUsage = inject(UserSongUsageService);
private showSongIndex = inject(ShowSongIndexService);
public users$ = this.session.users$;
@@ -34,4 +36,6 @@ export class UserService {
public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId);
public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> =>
this.showSongIndex.rebuildShowSongIds(onProgress);
}

View File

@@ -6,7 +6,7 @@
<mat-option>
<ngx-mat-select-search [formControl]="filteredSongsControl"></ngx-mat-select-search>
</mat-option>
@for (song of filteredSongs(); track song) {
@for (song of filteredSongs(); track song.id) {
<mat-option [value]="song.id">{{ song.title }}</mat-option>
}
</mat-select>

View File

@@ -18,6 +18,7 @@ declare global {
interface Window {
wgeneratorAdmin?: {
rebuildSongUsage(): Promise<unknown>;
rebuildShowSongIds(): Promise<unknown>;
};
}
}
@@ -49,6 +50,20 @@ bootstrapApplication(AppComponent, {
const userService = appRef.injector.get(UserService);
window.wgeneratorAdmin = {
rebuildSongUsage: () => userService.rebuildSongUsage(),
rebuildShowSongIds: async () => {
console.info('[wgeneratorAdmin] rebuildShowSongIds started');
const result = await userService.rebuildShowSongIds(progress => {
console.info(
`[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`,
{
showId: progress.showId,
showSongsProcessed: progress.showSongsProcessed,
}
);
});
console.info('[wgeneratorAdmin] rebuildShowSongIds finished', result);
return result;
},
};
})
.catch(err => console.error(err));

20
src/types/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare module 'qrcode' {
export interface QRCodeToDataURLOptions {
type?: string;
quality?: number;
width?: number;
height?: number;
color?: {
dark?: string;
light?: string;
};
}
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
const QRCode: {
toDataURL: typeof toDataURL;
};
export default QRCode;
}