diff --git a/package.json b/package.json index 03a008f..8aac11b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "ng build --configuration production", "build:dev": "ng build --configuration development", "deploy": "ng build --configuration production && firebase deploy", + "deploy-beta": "ng b && firebase hosting:channel:deploy beta", "test": "ng test", "lint": "ng lint --fix", "update": "ng update @angular/cdk @angular/cli @angular/core @angular/material && ncu -u && npm i && npm fix" diff --git a/src/app/modules/guest/guest-show-data.service.spec.ts b/src/app/modules/guest/guest-show-data.service.spec.ts index ac857e5..a4b4b74 100644 --- a/src/app/modules/guest/guest-show-data.service.spec.ts +++ b/src/app/modules/guest/guest-show-data.service.spec.ts @@ -1,4 +1,5 @@ import {TestBed} from '@angular/core/testing'; +import {Firestore} from '@angular/fire/firestore'; import {of} from 'rxjs'; import {DbService} from 'src/app/services/db.service'; import {GuestShowDataService} from './guest-show-data.service'; @@ -11,6 +12,7 @@ describe('GuestShowDataService', () => { let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; let colSpy: jasmine.Spy; let dbServiceSpy: jasmine.SpyObj; + let firestoreStub: Firestore; beforeEach(async () => { docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); @@ -26,9 +28,13 @@ describe('GuestShowDataService', () => { dbServiceSpy.doc$.and.returnValue(of({id: 'guest-1'}) as never); dbServiceSpy.doc.and.callFake(docSpy); dbServiceSpy.col.and.callFake(colSpy); + firestoreStub = {} as Firestore; await TestBed.configureTestingModule({ - providers: [{provide: DbService, useValue: dbServiceSpy}], + providers: [ + {provide: DbService, useValue: dbServiceSpy}, + {provide: Firestore, useValue: firestoreStub}, + ], }); service = TestBed.inject(GuestShowDataService); diff --git a/src/app/modules/guest/guest-show-data.service.ts b/src/app/modules/guest/guest-show-data.service.ts index 1b11171..163e142 100644 --- a/src/app/modules/guest/guest-show-data.service.ts +++ b/src/app/modules/guest/guest-show-data.service.ts @@ -1,4 +1,5 @@ -import {Injectable, inject} from '@angular/core'; +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; +import {doc, Firestore, getDoc} from '@angular/fire/firestore'; import {Observable} from 'rxjs'; import {shareReplay} from 'rxjs/operators'; import {DbService} from 'src/app/services/db.service'; @@ -9,6 +10,8 @@ import {GuestShow} from './guest-show'; }) export class GuestShowDataService { private dbService = inject(DbService); + private firestore = inject(Firestore); + private environmentInjector = inject(EnvironmentInjector); private collection = 'guest'; public list$: Observable = this.dbService.col$(this.collection).pipe( @@ -19,6 +22,18 @@ export class GuestShowDataService { ); public read$: (id: string) => Observable = (id: string): Observable => this.dbService.doc$(`${this.collection}/${id}`); + public read: (id: string) => Promise = async (id: string): Promise => { + const snapshot = await runInInjectionContext(this.environmentInjector, () => getDoc(doc(this.firestore, `${this.collection}/${id}`))); + + if (!snapshot.exists()) { + return null; + } + + return { + id: snapshot.id, + ...(snapshot.data() as Omit), + }; + }; public update$: (id: string, data: Partial) => Promise = async (id: string, data: Partial): Promise => await this.dbService.doc(this.collection + '/' + id).update(data); public add: (data: Partial) => Promise = async (data: Partial): Promise => (await this.dbService.col(this.collection).add(data)).id; diff --git a/src/app/modules/guest/guest-show.service.spec.ts b/src/app/modules/guest/guest-show.service.spec.ts index f16f76a..09f4896 100644 --- a/src/app/modules/guest/guest-show.service.spec.ts +++ b/src/app/modules/guest/guest-show.service.spec.ts @@ -38,11 +38,15 @@ describe('GuestShowService', () => { await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); - expect(guestShowDataServiceSpy.add).toHaveBeenCalledWith({ - showType: 'service-worship', - date: show.date, - songs, - }); + const [addPayload] = guestShowDataServiceSpy.add.calls.mostRecent().args as [Record]; + expect(addPayload).toEqual( + jasmine.objectContaining({ + showType: 'service-worship', + date: show.date, + songs, + }) + ); + expect(addPayload['updatedAt']).toEqual(jasmine.any(Date)); expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {shareId: 'share-1'}); }); @@ -53,11 +57,16 @@ describe('GuestShowService', () => { await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); - expect(guestShowDataServiceSpy.update$).toHaveBeenCalledWith('share-9', { - showType: 'service-worship', - date: show.date, - songs, - }); + const [shareId, updatePayload] = guestShowDataServiceSpy.update$.calls.mostRecent().args as [string, Record]; + expect(shareId).toBe('share-9'); + expect(updatePayload).toEqual( + jasmine.objectContaining({ + showType: 'service-worship', + date: show.date, + songs, + }) + ); + expect(updatePayload['updatedAt']).toEqual(jasmine.any(Date)); expect(guestShowDataServiceSpy.add).not.toHaveBeenCalled(); expect(showServiceSpy.update$).not.toHaveBeenCalled(); }); diff --git a/src/app/modules/guest/guest-show.service.ts b/src/app/modules/guest/guest-show.service.ts index 558f17e..084b9ce 100644 --- a/src/app/modules/guest/guest-show.service.ts +++ b/src/app/modules/guest/guest-show.service.ts @@ -15,6 +15,7 @@ export class GuestShowService { const data = { showType: show.showType, date: show.date, + updatedAt: new Date(), songs: songs, }; let shareId = show.shareId; diff --git a/src/app/modules/guest/guest-show.ts b/src/app/modules/guest/guest-show.ts index 726869f..3ec2887 100644 --- a/src/app/modules/guest/guest-show.ts +++ b/src/app/modules/guest/guest-show.ts @@ -5,5 +5,6 @@ export interface GuestShow { id: string; showType: string; date: Timestamp; + updatedAt?: Timestamp | Date; songs: Song[]; } diff --git a/src/app/modules/guest/guest.component.html b/src/app/modules/guest/guest.component.html index 9e8b63f..31177f3 100644 --- a/src/app/modules/guest/guest.component.html +++ b/src/app/modules/guest/guest.component.html @@ -1,12 +1,13 @@ -@if (show$|async; as show) { +@if (showState$ | async; as state) { +@if (state.status === 'loaded') {
-
{{ show.showType|showType }}
-
{{ show.date.toDate() | date: 'dd.MM.yyyy' }}
+
{{ state.show.showType | showType }}
+
{{ state.show.date | date: 'dd.MM.yyyy' }}
- @for (song of show.songs; track trackBy(i, song); let i = $index) { + @for (song of state.show.songs; track song.id) {
{{ song.title }}
+} @else if (state.status === 'loading') { +
+ Gastansicht wird geladen. +
+} @else if (state.status === 'not-found') { +
+ Für diesen Link wurde keine Gastansicht gefunden. +
+} @else { +
+ {{ state.message }} +
+} } diff --git a/src/app/modules/guest/guest.component.less b/src/app/modules/guest/guest.component.less index f326d99..176e776 100644 --- a/src/app/modules/guest/guest.component.less +++ b/src/app/modules/guest/guest.component.less @@ -57,3 +57,13 @@ app-song-text { margin-bottom: 50px; min-height: calc(100vh - 150px); } + +.empty-state { + color: var(--text-inverse); + display: flex; + justify-content: center; + align-items: center; + min-height: calc(100vh - 50px); + padding: 24px; + text-align: center; +} diff --git a/src/app/modules/guest/guest.component.spec.ts b/src/app/modules/guest/guest.component.spec.ts index 067f29b..f55e790 100644 --- a/src/app/modules/guest/guest.component.spec.ts +++ b/src/app/modules/guest/guest.component.spec.ts @@ -1,24 +1,97 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; - +import {ActivatedRoute} from '@angular/router'; +import {BehaviorSubject, of} from 'rxjs'; import {GuestComponent} from './guest.component'; +import {GuestShowDataService} from './guest-show-data.service'; describe('GuestComponent', () => { let component: GuestComponent; let fixture: ComponentFixture; + let guestShowDataServiceSpy: jasmine.SpyObj; + let guestShowSubject: BehaviorSubject; beforeEach(async () => { + guestShowSubject = new BehaviorSubject({ + id: 'guest-1', + showType: 'service-worship', + date: { + toDate: () => new Date('2026-03-20T00:00:00Z'), + }, + songs: [ + { + id: 'song-1', + title: 'Titel', + text: 'Text', + artist: 'Artist', + }, + ], + }); + guestShowDataServiceSpy = jasmine.createSpyObj('GuestShowDataService', ['read', 'read$']); + guestShowDataServiceSpy.read.and.resolveTo({ + id: 'guest-1', + showType: 'service-worship', + date: { + toDate: () => new Date('2026-03-20T00:00:00Z'), + }, + songs: [ + { + id: 'song-1', + title: 'Titel', + text: 'Text', + artist: 'Artist', + }, + ], + } as never); + guestShowDataServiceSpy.read$.and.returnValue(guestShowSubject.asObservable() as never); + await TestBed.configureTestingModule({ imports: [GuestComponent], + providers: [ + {provide: ActivatedRoute, useValue: {params: of({id: 'guest-1'})}}, + {provide: GuestShowDataService, useValue: guestShowDataServiceSpy}, + ], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(GuestComponent); component = fixture.componentInstance; fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); }); it('should create', () => { - void expect(component).toBeTruthy(); + expect(component).toBeTruthy(); + }); + + it('should load and render the guest show', () => { + expect(guestShowDataServiceSpy.read).toHaveBeenCalledWith('guest-1'); + expect(guestShowDataServiceSpy.read$).toHaveBeenCalledWith('guest-1'); + expect(fixture.nativeElement.textContent).toContain('Titel'); + expect(fixture.nativeElement.textContent).toContain('20.03.2026'); + }); + + it('should update the rendered guest show when live data changes', async () => { + guestShowSubject.next({ + id: 'guest-1', + showType: 'service-worship', + date: { + toDate: () => new Date('2026-03-21T00:00:00Z'), + }, + songs: [ + { + id: 'song-2', + title: 'Neuer Titel', + text: 'Neuer Text', + artist: 'Neue Artistin', + }, + ], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Neuer Titel'); + expect(fixture.nativeElement.textContent).toContain('21.03.2026'); }); }); diff --git a/src/app/modules/guest/guest.component.ts b/src/app/modules/guest/guest.component.ts index 02e7651..a3a2d3d 100644 --- a/src/app/modules/guest/guest.component.ts +++ b/src/app/modules/guest/guest.component.ts @@ -1,12 +1,13 @@ import {Component, CUSTOM_ELEMENTS_SCHEMA, inject} from '@angular/core'; +import {AsyncPipe, DatePipe} from '@angular/common'; import {GuestShowDataService} from './guest-show-data.service'; import {ActivatedRoute} from '@angular/router'; -import {map, switchMap} from 'rxjs/operators'; +import {catchError, map, switchMap} from 'rxjs/operators'; import {Song} from '../songs/services/song'; -import {ConfigService} from '../../services/config.service'; -import {AsyncPipe, DatePipe} from '@angular/common'; import {SongTextComponent} from '../../widget-modules/components/song-text/song-text.component'; import {ShowTypePipe} from '../../widget-modules/pipes/show-type-translater/show-type.pipe'; +import {concat, from, Observable, of} from 'rxjs'; +import {GuestShow} from './guest-show'; @Component({ selector: 'app-guest', @@ -18,13 +19,81 @@ import {ShowTypePipe} from '../../widget-modules/pipes/show-type-translater/show export class GuestComponent { private currentRoute = inject(ActivatedRoute); private service = inject(GuestShowDataService); - private configService = inject(ConfigService); - public show$ = this.currentRoute.params.pipe( + public showState$: Observable = this.currentRoute.params.pipe( map(param => param.id as string), - switchMap(id => this.service.read$(id)) + switchMap(id => + concat( + of({status: 'loading'}), + from(this.service.read(id)).pipe( + switchMap(show => { + const normalizedShow = this.normalizeShow(show); + + if (!normalizedShow) { + return of({status: 'not-found'}); + } + + return concat( + of({status: 'loaded', show: normalizedShow}), + this.service.read$(id).pipe( + map(liveShow => this.normalizeShow(liveShow)), + map(liveShow => (liveShow ? ({status: 'loaded', show: liveShow} as GuestShowState) : ({status: 'not-found'} as GuestShowState))), + catchError(() => of({status: 'error', message: 'Live-Aktualisierung fehlgeschlagen.'})) + ) + ); + }), + catchError(() => of({status: 'error', message: 'Gastansicht konnte nicht geladen werden.'})) + ) + ) + ) ); - public config$ = this.configService.get$(); public trackBy = (index: number, show: Song) => show.id; + + private normalizeShow(show: GuestShow | null): GuestShowView | null { + if (!show) { + return null; + } + + return { + ...show, + date: this.toDate(show.date), + updatedAt: this.toDate(show.updatedAt ?? null), + songs: Array.isArray(show.songs) ? show.songs : [], + }; + } + + private toDate(value: unknown): Date | null { + if (value instanceof Date) { + return value; + } + + if (typeof value === 'object' && value !== null) { + if ('toDate' in value && typeof value.toDate === 'function') { + return value.toDate() as Date; + } + + if ('seconds' in value && typeof value.seconds === 'number') { + return new Date(value.seconds * 1000); + } + } + + if (typeof value === 'string' || typeof value === 'number') { + const parsedDate = new Date(value); + return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; + } + + return null; + } } + +interface GuestShowView extends Omit { + date: Date | null; + updatedAt: Date | null; +} + +type GuestShowState = + | {status: 'loading'} + | {status: 'not-found'} + | {status: 'error'; message: string} + | {status: 'loaded'; show: GuestShowView}; diff --git a/src/app/widget-modules/pipes/show-type-translater/show-type.pipe.ts b/src/app/widget-modules/pipes/show-type-translater/show-type.pipe.ts index 1f5ce29..bec186c 100644 --- a/src/app/widget-modules/pipes/show-type-translater/show-type.pipe.ts +++ b/src/app/widget-modules/pipes/show-type-translater/show-type.pipe.ts @@ -11,7 +11,7 @@ export class ShowTypePipe implements PipeTransform { case 'service-praise': return 'Gottesdienst Lobpreis'; case 'home-group-big': - return 'großer Hauskreis'; + return 'Großer Hauskreis'; case 'home-group': return 'Hauskreis'; case 'prayer-group': @@ -21,9 +21,9 @@ export class ShowTypePipe implements PipeTransform { case 'kids-group': return 'Kinderkreis'; case 'misc-public': - return 'sonstige öffentliche Veranstaltung'; + return 'Sonstige öffentliche Veranstaltung'; case 'misc-private': - return 'sonstige private Veranstaltung'; + return 'Sonstige private Veranstaltung'; } return 'unbekannter Veranstaltungstyp';