diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c5a9b9..c657932 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,6 +21,7 @@ "**/*.code-search": true, "**/.cache": true, "**/dist": true, + "**/.angular": true, "**/coverage": true, "pnpm-lock.yaml": true }, diff --git a/src/client/.eslintrc.json b/src/client/.eslintrc.json index 317baa5..eb6b961 100644 --- a/src/client/.eslintrc.json +++ b/src/client/.eslintrc.json @@ -4,6 +4,7 @@ "project": "./tsconfig.json" }, "rules": { - "@angular-eslint/prefer-on-push-component-change-detection": "error" + "@angular-eslint/prefer-on-push-component-change-detection": "error", + "no-console": "error" } } diff --git a/src/client/src/app/+state/action-state.ts b/src/client/src/app/+state/action-state.ts index 7b9f1a6..87c6c1b 100644 --- a/src/client/src/app/+state/action-state.ts +++ b/src/client/src/app/+state/action-state.ts @@ -69,8 +69,6 @@ export type HttpActionCreator< error: _ActionCreatorWithResponse<`[${TScope}] [Error] ${TName}`, TProps, unknown>; }; -type x = HttpActionCreator<'a', 'b', { a: string }, { b: number }>; - export function createHttpAction() { return (scope: TScope, name: TName) => { const action = createAction(`[${scope}] ${name}`, creator()); @@ -97,6 +95,7 @@ export function handleHttpAction< TActionStateName extends TInferredState extends { actionStates: Record } ? keyof TInferredState['actionStates'] : never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any TAction extends HttpActionCreator, TProps = Parameters[0], TInferredState = TState, @@ -134,12 +133,14 @@ export function handleHttpAction< } else if (props.type === action.success.type) { actionStates[actionStateName as string] = successActionState; } else if (props.type === action.error.type) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any actionStates[actionStateName as string] = errorActionState((props as any).response); } }) ); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function onHttpAction>( action: T, actionStateSelector?: Selector, diff --git a/src/client/src/app/+state/events/actions/load-events.action.ts b/src/client/src/app/+state/events/actions/load-events.action.ts index c7e989e..b72b015 100644 --- a/src/client/src/app/+state/events/actions/load-events.action.ts +++ b/src/client/src/app/+state/events/actions/load-events.action.ts @@ -29,7 +29,10 @@ export const loadEventsReducers: Reducers = [ }) ) ), - handleHttpAction('load', loadEventsAction, { condition: (s, p) => !p.silent }), + handleHttpAction('load', loadEventsAction, { + condition: (s, p) => !p.silent, + startCondition: (s, p) => s.actionStates.load.state === 'none' || p.reload === true, + }), ]; export const loadEventsEffects: Effects = { diff --git a/src/client/src/app/+state/events/events.selectors.ts b/src/client/src/app/+state/events/events.selectors.ts index bc4336e..5b0bc62 100644 --- a/src/client/src/app/+state/events/events.selectors.ts +++ b/src/client/src/app/+state/events/events.selectors.ts @@ -21,12 +21,19 @@ export function selectEventsActionState(action: keyof EventsFeatureState['action return createDistinctSelector(selectEventsActionStates, state => state[action]); } -export function selectEvent(id: string) { - return createDistinctSelector(selectEventsFeature, state => state.entities[id]); +export function selectEvent(id: string | null | undefined) { + return createDistinctSelector(selectEventsFeature, state => + id ? state.entities[id] ?? null : null + ); } -export function selectEventTimeslot(eventId: string, timeslotId: string) { +export function selectEventTimeslot( + eventId: string | null | undefined, + timeslotId: string | null | undefined +) { return createDistinctSelector(selectEventsFeature, state => - state.entities[eventId]?.timeslots.find(x => x.id === timeslotId) + eventId && timeslotId + ? state.entities[eventId]?.timeslots.find(x => x.id === timeslotId) ?? null + : null ); } diff --git a/src/client/src/app/+state/events/events.utils.ts b/src/client/src/app/+state/events/events.utils.ts index f5345e2..8c3db95 100644 --- a/src/client/src/app/+state/events/events.utils.ts +++ b/src/client/src/app/+state/events/events.utils.ts @@ -19,13 +19,24 @@ export function keepEventsLoaded(options?: OptionalInjector) { .subscribe(() => store.dispatch(loadEventsAction({ reload: true, silent: true }))); } -export function keepEventLoaded(eventId: Signal, options?: OptionalInjector) { +export function keepEventLoaded( + eventId: Signal, + options?: OptionalInjector +) { const store = injectEx(Store, options); - effect(() => store.dispatch(loadEventAction({ eventId: eventId(), reload: false })), { - ...options, - allowSignalWrites: true, - }); + effect( + () => { + const id = eventId(); + if (id) { + store.dispatch(loadEventAction({ eventId: id, reload: false })); + } + }, + { + ...options, + allowSignalWrites: true, + } + ); store .select(selectEventsActionState('loadOne')) @@ -33,7 +44,10 @@ export function keepEventLoaded(eventId: Signal, options?: OptionalInjec filter(x => x.state === 'none'), takeUntilDestroyed(injectEx(DestroyRef, options)) ) - .subscribe(() => - store.dispatch(loadEventAction({ eventId: eventId(), reload: true, silent: true })) - ); + .subscribe(() => { + const id = eventId(); + if (id) { + store.dispatch(loadEventAction({ eventId: id, reload: true, silent: true })); + } + }); } diff --git a/src/client/src/app/+state/functional-effect.ts b/src/client/src/app/+state/functional-effect.ts index a9e2743..b71a162 100644 --- a/src/client/src/app/+state/functional-effect.ts +++ b/src/client/src/app/+state/functional-effect.ts @@ -23,6 +23,7 @@ function _createFunctionalEffect Observable>( return createEffect(source, actualConfig); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any type EffectResult = Observable | ((...args: any[]) => Observable); type ConditionallyDisallowActionCreator = DT extends false ? unknown @@ -46,6 +47,7 @@ export type CreateFunctionalEffectFunction = typeof _createFunctionalEffect & { }; export const createFunctionalEffect: CreateFunctionalEffectFunction = (() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (_createFunctionalEffect as any).dispatching = _createDispatchingFunctionalEffect; return _createFunctionalEffect as CreateFunctionalEffectFunction; })(); diff --git a/src/client/src/app/+state/maps/actions/load-maps.action.ts b/src/client/src/app/+state/maps/actions/load-maps.action.ts index 5354db0..9c6de12 100644 --- a/src/client/src/app/+state/maps/actions/load-maps.action.ts +++ b/src/client/src/app/+state/maps/actions/load-maps.action.ts @@ -24,7 +24,10 @@ export const loadMapsReducers: Reducers = [ props.reload ? mapsEntityAdapter.removeAll(state) : state ) ), - handleHttpAction('load', loadMapsAction, { condition: (s, p) => !p.silent }), + handleHttpAction('load', loadMapsAction, { + condition: (s, p) => !p.silent, + startCondition: (s, p) => s.actionStates.load.state === 'none' || p.reload === true, + }), ]; export const loadMapsEffects: Effects = { diff --git a/src/client/src/app/+state/player-events/actions/load-player-events.action.ts b/src/client/src/app/+state/player-events/actions/load-player-events.action.ts index 65cc2fb..7a0d41a 100644 --- a/src/client/src/app/+state/player-events/actions/load-player-events.action.ts +++ b/src/client/src/app/+state/player-events/actions/load-player-events.action.ts @@ -32,7 +32,10 @@ export const loadPlayerEventsReducers: Reducers = [ }) ) ), - handleHttpAction('load', loadPlayerEventsAction, { condition: (s, p) => !p.silent }), + handleHttpAction('load', loadPlayerEventsAction, { + condition: (s, p) => !p.silent, + startCondition: (s, p) => s.actionStates.load.state === 'none' || p.reload === true, + }), ]; export const loadPlayerEventsEffects: Effects = { diff --git a/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts b/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts index 17bb1d4..b851953 100644 --- a/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts +++ b/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts @@ -5,7 +5,6 @@ import { switchMap } from 'rxjs'; import { UserSettingsService } from '../../../api/services'; import { UserSettings } from '../../../models/parsed-models'; -import { RealtimeEventsService } from '../../../services/realtime-events.service'; import { isEmptyObject, removeUndefinedProperties } from '../../../utils/common.utils'; import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state'; import { createFunctionalEffect } from '../../functional-effect'; @@ -35,19 +34,18 @@ export const updateUserSettingsReducers: Reducers = [ ]; export const updateUserSettingsEffects: Effects = { - updateUserSettings$: createFunctionalEffect.dispatching( - (api = inject(UserSettingsService), events = inject(RealtimeEventsService)) => - onHttpAction(updateUserSettingsAction, selectUserSettingsActionState('update')).pipe( - switchMap(({ props }) => - toHttpAction( - updateUserSettings(api, props), - updateUserSettingsAction, - props - // , () => - // events.skipEvent('userSettingsChanged') - ) + updateUserSettings$: createFunctionalEffect.dispatching((api = inject(UserSettingsService)) => + onHttpAction(updateUserSettingsAction, selectUserSettingsActionState('update')).pipe( + switchMap(({ props }) => + toHttpAction( + updateUserSettings(api, props), + updateUserSettingsAction, + props + // , () => + // events.skipEvent('userSettingsChanged') ) ) + ) ), }; diff --git a/src/client/src/app/+state/user-settings/user-settings.utils.ts b/src/client/src/app/+state/user-settings/user-settings.utils.ts index d7527e3..dae9e78 100644 --- a/src/client/src/app/+state/user-settings/user-settings.utils.ts +++ b/src/client/src/app/+state/user-settings/user-settings.utils.ts @@ -1,7 +1,7 @@ import { DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; -import { filter, map } from 'rxjs'; +import { filter } from 'rxjs'; import { loadUserSettingsAction } from './user-settings.actions'; import { selectUserSettingsActionState } from './user-settings.selectors'; @@ -9,12 +9,12 @@ import { injectEx, OptionalInjector } from '../../utils/angular.utils'; export function keepUserSettingsLoaded(options?: OptionalInjector) { const store = injectEx(Store, options); + store.dispatch(loadUserSettingsAction({ reload: false })); store .select(selectUserSettingsActionState('load')) .pipe( filter(x => x.state === 'none'), - map((_, index) => index), takeUntilDestroyed(injectEx(DestroyRef, options)) ) - .subscribe(i => store.dispatch(loadUserSettingsAction({ reload: true, silent: i > 0 }))); + .subscribe(() => store.dispatch(loadUserSettingsAction({ reload: true, silent: true }))); } diff --git a/src/client/src/app/+state/users/actions/update-user.action.ts b/src/client/src/app/+state/users/actions/update-user.action.ts index b980f91..21c026f 100644 --- a/src/client/src/app/+state/users/actions/update-user.action.ts +++ b/src/client/src/app/+state/users/actions/update-user.action.ts @@ -25,9 +25,9 @@ export const updateUserEffects = { (store = inject(Store), api = inject(UserAdministrationService)) => onHttpAction(updateUserAction, selectUsersActionState('update')).pipe( concatLatestFrom(({ props }) => store.select(selectUser(props.id))), - filter(([, oldUser]) => !!oldUser), + filter((x): x is [ReturnType, User] => !!x[1]), switchMap(([{ props }, oldUser]) => - toHttpAction(updateUser(api, props, oldUser!), updateUserAction, props) + toHttpAction(updateUser(api, props, oldUser), updateUserAction, props) ) ) ), diff --git a/src/client/src/app/app.config.ts b/src/client/src/app/app.config.ts index 9ef7db9..3475461 100644 --- a/src/client/src/app/app.config.ts +++ b/src/client/src/app/app.config.ts @@ -40,7 +40,11 @@ export const appConfig: ApplicationConfig = { router: routerReducer, }), provideRouterStore(), - provideStoreDevtools({ name: `Minigolf Friday (${Math.random().toString(16).substring(2)})` }), + provideStoreDevtools({ + name: `Minigolf Friday (${Math.random().toString(16).substring(2)})`, + autoPause: true, + maxAge: 1000, + }), provideAppState(), provideUsersState(), provideMapsState(), diff --git a/src/client/src/app/components/events/event-details/event-details.component.ts b/src/client/src/app/components/events/event-details/event-details.component.ts index 3f2b66c..5f5a571 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.ts +++ b/src/client/src/app/components/events/event-details/event-details.component.ts @@ -102,13 +102,10 @@ export class EventDetailsComponent { () => this.canBuildInstances() && this.event() && !this.event()?.startedAt ); - protected readonly canCommit = computed( - () => - this.event() && - this.event()?.staged && - this.event()?.timeslots && - this.event()!.timeslots.length > 0 - ); + protected readonly canCommit = computed(() => { + const event = this.event(); + return event && event.staged && event.timeslots && event.timeslots.length > 0; + }); protected readonly allowToStart = computed( () => this.hasInstances() && this.allTimeslotsHaveMaps() @@ -138,10 +135,13 @@ export class EventDetailsComponent { } protected deleteEvent() { + const event = this.event(); + if (!event) return; + this._confirmationService.confirm({ header: this.translations.events_deleteDialog_title(), message: interpolate(this.translations.events_deleteDialog_text(), { - date: formatDate(this.event()!.date, 'mediumDate', this.locale()), + date: formatDate(event.date, 'mediumDate', this.locale()), }), acceptLabel: this.translations.shared_delete(), acceptButtonStyleClass: 'p-button-danger', @@ -151,7 +151,7 @@ export class EventDetailsComponent { accept: () => { this._store.dispatch( removeEventAction({ - eventId: this.eventId()!, + eventId: event.id, }) ); }, @@ -159,10 +159,13 @@ export class EventDetailsComponent { } protected startEvent() { + const event = this.event(); + if (!event) return; + this._confirmationService.confirm({ header: this.translations.events_startDialog_title(), message: interpolate(this.translations.events_startDialog_text(), { - date: formatDate(this.event()!.date, 'mediumDate', this.locale()), + date: formatDate(event.date, 'mediumDate', this.locale()), }), acceptLabel: this.translations.shared_start(), acceptButtonStyleClass: 'p-button-success', @@ -172,7 +175,7 @@ export class EventDetailsComponent { accept: () => { this._store.dispatch( startEventAction({ - eventId: this.eventId()!, + eventId: event.id, }) ); }, @@ -180,10 +183,13 @@ export class EventDetailsComponent { } protected commitEvent() { + const event = this.event(); + if (!event) return; + this._confirmationService.confirm({ header: this.translations.events_commitDialog_title(), message: interpolate(this.translations.events_commitDialog_text(), { - date: formatDate(this.event()!.date, 'mediumDate', this.locale()), + date: formatDate(event.date, 'mediumDate', this.locale()), }), acceptLabel: this.translations.shared_commit(), acceptButtonStyleClass: 'p-button-success', @@ -193,7 +199,7 @@ export class EventDetailsComponent { accept: () => { this._store.dispatch( commitEventAction({ - eventId: this.eventId()!, + eventId: event.id, commit: true, }) ); diff --git a/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts b/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts index e2665c4..6384a60 100644 --- a/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts +++ b/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts @@ -103,7 +103,6 @@ export class EventTimeslotDialogComponent { const timeslot = untracked(() => this.timeslot()); if (this.visible() && timeslot) { - console.log('timeslot', timeslot); untracked(() => this.form.setValue({ time: dateWithTime(new Date(), timeslot.time), diff --git a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts index 833b829..e9832d8 100644 --- a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts +++ b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts @@ -38,11 +38,15 @@ import { import { InterpolatePipe, interpolate } from '../../../directives/interpolate.pipe'; import { EventInstancePreconfiguration, User } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; -import { ifTruthy } from '../../../utils/common.utils'; +import { ifTruthy, isNullish } from '../../../utils/common.utils'; import { dateWithTime, timeToString } from '../../../utils/date.utils'; import { errorToastEffect, selectSignal } from '../../../utils/ngrx.utils'; import { EventTimeslotDialogComponent } from '../event-timeslot-dialog/event-timeslot-dialog.component'; +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + @Component({ selector: 'app-event-timeslot', standalone: true, @@ -74,10 +78,10 @@ export class EventTimeslotComponent { protected readonly locale = this._translateService.language; private readonly eventId = toSignal( - this._activatedRoute.params.pipe(map(data => data['eventId'])) + this._activatedRoute.params.pipe(map(data => asString(data['eventId']))) ); private readonly timeslotId = toSignal( - this._activatedRoute.params.pipe(map(data => data['timeslotId'])) + this._activatedRoute.params.pipe(map(data => asString(data['timeslotId']))) ); private readonly actionState = selectSignal(selectEventsActionState('loadOne')); private readonly loadUsersActionState = selectSignal(selectUsersActionState('load')); @@ -167,15 +171,18 @@ export class EventTimeslotComponent { } protected addPreconfig() { - this._store.dispatch( - addEventPreconfigAction({ - eventId: this.eventId()!, - timeslotId: this.timeslotId()!, - }) - ); + const eventId = this.eventId(); + const timeslotId = this.timeslotId(); + if (isNullish(eventId) || isNullish(timeslotId)) return; + + this._store.dispatch(addEventPreconfigAction({ eventId, timeslotId })); } protected removePreconfig(preconfig: EventInstancePreconfiguration) { + const eventId = this.eventId(); + const timeslotId = this.timeslotId(); + if (isNullish(eventId) || isNullish(timeslotId)) return; + this._confirmationService.confirm({ header: this.translations.events_deletePreconfigDialog_title(), message: interpolate(this.translations.events_deletePreconfigDialog_text(), preconfig), @@ -186,20 +193,20 @@ export class EventTimeslotComponent { rejectButtonStyleClass: 'p-button-text', accept: () => this._store.dispatch( - removeEventPreconfigAction({ - eventId: this.eventId()!, - timeslotId: this.timeslotId()!, - preconfigId: preconfig.id, - }) + removeEventPreconfigAction({ eventId, timeslotId, preconfigId: preconfig.id }) ), }); } protected addPlayerToPreconfig(preconfigId: string, userId: string) { + const eventId = this.eventId(); + const timeslotId = this.timeslotId(); + if (isNullish(eventId) || isNullish(timeslotId)) return; + this._store.dispatch( addPlayerToEventPreconfigurationAction({ - eventId: this.eventId()!, - timeslotId: this.timeslotId()!, + eventId, + timeslotId, preconfigId, playerId: userId, }) @@ -207,10 +214,14 @@ export class EventTimeslotComponent { } protected removePlayerFromPreconfig(preconfigId: string, userId: string) { + const eventId = this.eventId(); + const timeslotId = this.timeslotId(); + if (isNullish(eventId) || isNullish(timeslotId)) return; + this._store.dispatch( removePlayerFromPreconfigAction({ - eventId: this.eventId()!, - timeslotId: this.timeslotId()!, + eventId, + timeslotId, preconfigId, playerId: userId, }) @@ -218,10 +229,14 @@ export class EventTimeslotComponent { } protected deleteTimeslot() { + const eventId = this.eventId(); + const timeslot = this.timeslot(); + if (isNullish(eventId) || isNullish(timeslot)) return; + this._confirmationService.confirm({ header: this.translations.events_deleteTimeslotDialog_title(), message: interpolate(this.translations.events_deleteTimeslotDialog_text(), { - time: timeToString(this.timeslot()!.time, 'minutes'), + time: timeToString(timeslot.time, 'minutes'), }), acceptLabel: this.translations.shared_delete(), acceptButtonStyleClass: 'p-button-danger', @@ -229,12 +244,7 @@ export class EventTimeslotComponent { rejectLabel: this.translations.shared_cancel(), rejectButtonStyleClass: 'p-button-text', accept: () => { - this._store.dispatch( - removeEventTimeslotAction({ - eventId: this.eventId()!, - timeslotId: this.timeslotId()!, - }) - ); + this._store.dispatch(removeEventTimeslotAction({ eventId, timeslotId: timeslot.id })); }, }); } diff --git a/src/client/src/app/components/login/login.component.ts b/src/client/src/app/components/login/login.component.ts index adb4efb..bf39021 100644 --- a/src/client/src/app/components/login/login.component.ts +++ b/src/client/src/app/components/login/login.component.ts @@ -22,6 +22,7 @@ import { ErrorTextDirective } from '../../directives/error-text.directive'; import { InterpolatePipe } from '../../directives/interpolate.pipe'; import { OnEnterDirective } from '../../directives/on-enter.directive'; import { AuthService, SignInResult } from '../../services/auth.service'; +import { Logger } from '../../services/logger.service'; import { TranslateService } from '../../services/translate.service'; @Component({ @@ -112,7 +113,7 @@ export class LoginComponent { const result = await this._authService.signIn(loginToken); this.loginResult.set(result); } catch (error) { - console.error(error); + Logger.logError('LoginComponent', 'Failed to sign in', error); this.loginResult.set('error'); } finally { this.isLoggingIn.set(false); diff --git a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts index 6d25480..494f25c 100644 --- a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts +++ b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts @@ -121,6 +121,7 @@ export class PlayerEventDetailsComponent { .filter(x => x.instance) .map(x => ({ timeslot: x, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion instance: x.instance!, })) ); diff --git a/src/client/src/app/components/users/user-dialog/user-dialog.component.ts b/src/client/src/app/components/users/user-dialog/user-dialog.component.ts index c61d850..53ada31 100644 --- a/src/client/src/app/components/users/user-dialog/user-dialog.component.ts +++ b/src/client/src/app/components/users/user-dialog/user-dialog.component.ts @@ -129,6 +129,9 @@ export class UserDialogComponent { } this.close(); }); + actions$ + .pipe(ofType(loadUserLoginTokenAction.success), takeUntilDestroyed()) + .subscribe(() => this.tokenVisible.set(true)); effect( () => { @@ -198,10 +201,15 @@ export class UserDialogComponent { } protected loadLoginToken() { - if (!this.loginToken()) { - this._store.dispatch(loadUserLoginTokenAction({ userId: this.userToUpdate()!.id })); + if (this.loginToken()) { + this.tokenVisible.set(true); + return; } - this.tokenVisible.set(true); + + const user = this.userToUpdate(); + if (!user) return; + + this._store.dispatch(loadUserLoginTokenAction({ userId: user.id })); } protected async copyLoginToken(token: string | null | undefined) { diff --git a/src/client/src/app/services/auth.guard.ts b/src/client/src/app/services/auth.guard.ts index c8f3063..ff79d26 100644 --- a/src/client/src/app/services/auth.guard.ts +++ b/src/client/src/app/services/auth.guard.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { Router, RouterStateSnapshot } from '@angular/router'; import { AuthService } from './auth.service'; +import { Logger } from './logger.service'; @Injectable({ providedIn: 'root' }) export class AuthGuard { @@ -11,10 +12,15 @@ export class AuthGuard { public async canActivate(state: RouterStateSnapshot, options: { needsAdminRights: boolean }) { const user = this._authService.token()?.user; if (!user) { + Logger.logDebug('AuthGuard', 'User not logged in, redirecting to login page'); this.navigateToLogin(state.url); return false; } if (options.needsAdminRights && !user.roles.includes('admin')) { + Logger.logDebug( + 'AuthGuard', + 'User does not have admin rights, redirecting to unauthorized page' + ); this.navigateToUnauthorized(state.url); return false; } diff --git a/src/client/src/app/services/auth.interceptor.ts b/src/client/src/app/services/auth.interceptor.ts index 6a3a7ab..a38e220 100644 --- a/src/client/src/app/services/auth.interceptor.ts +++ b/src/client/src/app/services/auth.interceptor.ts @@ -8,7 +8,7 @@ import { AuthService } from './auth.service'; export class AuthInterceptor implements HttpInterceptor { private readonly _authService = inject(AuthService); - public intercept(req: HttpRequest, next: HttpHandler): Observable> { + public intercept(req: HttpRequest, next: HttpHandler): Observable> { if (req.url.includes('api/auth/token')) { return next.handle(req); } diff --git a/src/client/src/app/services/auth.service.ts b/src/client/src/app/services/auth.service.ts index d9814b3..35fd262 100644 --- a/src/client/src/app/services/auth.service.ts +++ b/src/client/src/app/services/auth.service.ts @@ -3,17 +3,18 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { filter, Unsubscribable } from 'rxjs'; +import { Logger } from './logger.service'; import { AuthTokenInfo, getAuthTokenInfo, getLoginToken, setAuthTokenInfo, setLoginToken, + setLogLevelEnabled, } from './storage'; import { AuthenticationService } from '../api/services'; import { environment } from '../environments/environment'; import { onDocumentVisibilityChange$ } from '../utils/event.utils'; -import { assertBody } from '../utils/http.utils'; import type { Eruda } from 'eruda'; @@ -29,7 +30,7 @@ export class AuthService implements OnDestroy { private readonly _beforeSignOut: (() => Promise)[] = []; private readonly _token = signal(undefined); - private _tokenRefreshTimeout?: any; + private _tokenRefreshTimeout?: ReturnType; public readonly token = this._token.asReadonly(); public readonly user = computed(() => this.token()?.user); @@ -39,15 +40,18 @@ export class AuthService implements OnDestroy { effect(() => { const token = this._token(); - const isDev = token?.user?.roles.includes('developer'); + const isDev = token?.user?.roles.includes('developer') ?? false; + setLogLevelEnabled('debug', isDev); if (isDev) { (eruda ? Promise.resolve(eruda) : import('eruda').then(x => (eruda = x.default))).then(x => x.init() ); - } else if (eruda) { - eruda.destroy(); + } else { + eruda?.destroy(); } + Logger.logDebug('AuthService', 'Token changed', { token }); + if (token === undefined) return; setAuthTokenInfo(token); if (token) { @@ -68,11 +72,13 @@ export class AuthService implements OnDestroy { } public async init() { + Logger.logDebug('AuthService', 'Initializing AuthService'); if (this._token() !== undefined) { throw new Error('AuthService already initialized'); } if (!environment.authenticationRequired) { + Logger.logDebug('AuthService', 'Authentication disabled, using stub token'); this._token.set({ token: 'abc', expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), @@ -88,23 +94,30 @@ export class AuthService implements OnDestroy { const token = getAuthTokenInfo(); if (token && token.expiresAt.getTime() > Date.now() + 15 * 60 * 1000) { + Logger.logDebug('AuthService', 'Using stored auth token'); this._token.set(token); return; } const loginToken = getLoginToken(); - if (loginToken && (await this.signIn(loginToken)) === 'success') { - return; + if (loginToken) { + Logger.logDebug('AuthService', 'Using stored login token'); + if ((await this.signIn(loginToken)) === 'success') { + return; + } } + Logger.logDebug('AuthService', 'No stored token, setting token to null'); this._token.set(null); } public async ensureTokenNotExpired() { const expiration = this._token()?.expiresAt; if (!expiration || expiration.getTime() < Date.now() + 60 * 1000) { + Logger.logDebug('AuthService', 'Token expired, refreshing'); await this.refreshToken(); } else if (expiration) { + Logger.logDebug('AuthService', 'Token expires at', expiration); this.updateTokenRefreshTimeout(expiration); } } @@ -112,23 +125,29 @@ export class AuthService implements OnDestroy { public async signIn(loginToken: string): Promise { if (!environment.authenticationRequired) return 'success'; + Logger.logDebug('AuthService', 'Signing in with login token'); const response = await this._api.getToken({ body: { loginToken } }); + Logger.logDebug('AuthService', 'Sign in response', response); if (!response.ok) { if (response.status === 401) { - console.error('Invalid login token', { cause: response }); + Logger.logError('AuthService', 'Invalid login token', { cause: response }); return 'invalid-token'; } else { - console.error('Error while signing in', { cause: response }); + Logger.logError('AuthService', 'Error while signing in', { cause: response }); return 'error'; } } - const body = assertBody(response); + if (!response.body) { + Logger.logError('AuthService', 'No body in response', { response }); + return 'error'; + } + setLoginToken(loginToken); this._token.set({ - token: body.token, - expiresAt: new Date(body.tokenExpiration), - user: body.user, + token: response.body.token, + expiresAt: new Date(response.body.tokenExpiration), + user: response.body.user, }); return 'success'; } @@ -136,7 +155,15 @@ export class AuthService implements OnDestroy { public async signOut() { if (!environment.authenticationRequired) return; - await Promise.all(this._beforeSignOut.map(x => x())); + Logger.logDebug('AuthService', 'Signing out'); + + try { + Logger.logDebug('AuthService', 'Executing before sign out actions'); + await Promise.all(this._beforeSignOut.map(x => x())); + } catch (error) { + Logger.logError('AuthService', 'Error while executing before sign out actions', error); + } + setLoginToken(null); this._token.set(null); @@ -144,6 +171,7 @@ export class AuthService implements OnDestroy { } public onBeforeSignOut(action: () => Promise): Unsubscribable { + Logger.logDebug('AuthService', 'Adding before sign out action', Error().stack); this._beforeSignOut.push(action); return { unsubscribe: () => { @@ -160,6 +188,7 @@ export class AuthService implements OnDestroy { } private updateTokenRefreshTimeout(expiration: Date) { + Logger.logDebug('AuthService', 'Updating token refresh timeout', expiration); if (this._tokenRefreshTimeout) clearTimeout(this._tokenRefreshTimeout); this._tokenRefreshTimeout = setTimeout( () => { @@ -170,7 +199,7 @@ export class AuthService implements OnDestroy { } private async refreshToken() { - console.log('Refreshing token'); + Logger.logDebug('AuthService', 'Refreshing token'); const loginToken = getLoginToken(); if (loginToken) { await this.signIn(loginToken); @@ -180,6 +209,7 @@ export class AuthService implements OnDestroy { } private clearTokenRefreshTimeout() { + Logger.logDebug('AuthService', 'Clearing token refresh timeout'); if (this._tokenRefreshTimeout) clearTimeout(this._tokenRefreshTimeout); this._tokenRefreshTimeout = undefined; } diff --git a/src/client/src/app/services/logger.service.ts b/src/client/src/app/services/logger.service.ts new file mode 100644 index 0000000..156df7f --- /dev/null +++ b/src/client/src/app/services/logger.service.ts @@ -0,0 +1,28 @@ +/* eslint-disable no-console */ +import { getLogLevelEnabled } from './storage'; + +export class Logger { + public static logError(origin: string, message: string, ...optionalParams: unknown[]): void { + if (getLogLevelEnabled('error')) { + console.error(`%c[ERROR: ${origin}]`, 'color: #FF4E00', message, ...optionalParams); + } + } + + public static logWarn(origin: string, message: string, ...optionalParams: unknown[]): void { + if (getLogLevelEnabled('warn')) { + console.warn(`%c[WARN: ${origin}]`, 'color: #FFAE00', message, ...optionalParams); + } + } + + public static logInfo(origin: string, message: string, ...optionalParams: unknown[]): void { + if (getLogLevelEnabled('info')) { + console.info(`%c[INFO: ${origin}]`, 'color: #0094FF', message, ...optionalParams); + } + } + + public static logDebug(origin: string, message: string, ...optionalParams: unknown[]): void { + if (getLogLevelEnabled('debug')) { + console.debug(`%c[DEBUG: ${origin}]`, 'color: #B200FF', message, ...optionalParams); + } + } +} diff --git a/src/client/src/app/services/realtime-events.service.ts b/src/client/src/app/services/realtime-events.service.ts index 5877a5b..affc9b5 100644 --- a/src/client/src/app/services/realtime-events.service.ts +++ b/src/client/src/app/services/realtime-events.service.ts @@ -9,10 +9,16 @@ import { untracked, } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; -import { HubConnection, HubConnectionBuilder, HubConnectionState } from '@microsoft/signalr'; +import { + HubConnection, + HubConnectionBuilder, + HubConnectionState, + InvocationMessage, +} from '@microsoft/signalr'; import { defer, EMPTY, EmptyError, filter, firstValueFrom, map, pairwise, startWith } from 'rxjs'; import { AuthService } from './auth.service'; +import { Logger } from './logger.service'; import { AuthTokenInfo } from './storage'; import { UserChangedRealtimeEvent, @@ -95,6 +101,7 @@ export class RealtimeEventsService implements OnDestroy { if (!this._hubConnection) { await this.connect(); } else if (this._hubConnection.state !== HubConnectionState.Connected) { + Logger.logDebug('RealtimeEventsService', 'Restarting realtime events connection'); await this._hubConnection.stop(); this.start(); } @@ -120,10 +127,10 @@ export class RealtimeEventsService implements OnDestroy { }) .withAutomaticReconnect( new SignalrRetryPolicy((error, nextDelay) => - console.warn( + Logger.logWarn( + 'RealtimeEventsService', `Realtime events connection lost. Retry connection in ${nextDelay}ms.`, - error, - nextDelay + error ) ) ) @@ -148,11 +155,25 @@ export class RealtimeEventsService implements OnDestroy { connection.onreconnected(() => this._isConnected.set(true)); connection.onclose(() => this._isConnected.set(false)); + if (connection['_invokeClientMethod']) { + const oldHandler = connection['_invokeClientMethod']; + connection['_invokeClientMethod'] = (message: InvocationMessage) => { + Logger.logDebug('RealtimeEventsService', 'Received realtime event:', { + methodName: message.target, + arguments: message.arguments, + }); + oldHandler.call(connection, message); + }; + } else { + Logger.logWarn('RealtimeEventsService', 'Cannot hook into HubConnection._invokeClientMethod'); + } + this._hubConnection = connection; await this.start(); } private async start() { + Logger.logDebug('RealtimeEventsService', 'Starting realtime events connection'); try { await firstValueFrom( defer(() => @@ -162,7 +183,8 @@ export class RealtimeEventsService implements OnDestroy { ).pipe( retryWithPolicy( new SignalrRetryPolicy((error, nextDelay) => - console.warn( + Logger.logWarn( + 'RealtimeEventsService', `Realtime events connection unsuccessful. Retry connection in ${nextDelay}ms.`, error ) @@ -170,12 +192,20 @@ export class RealtimeEventsService implements OnDestroy { ) ) ); + Logger.logDebug( + 'RealtimeEventsService', + 'Realtime events connection started with state', + this._hubConnection?.state + ); if (this._hubConnection?.state === HubConnectionState.Connected) { this._isConnected.set(true); } } catch (ex) { if (!(ex instanceof EmptyError)) { + Logger.logError('RealtimeEventsService', 'Failed to start realtime events connection', ex); throw ex; + } else { + Logger.logDebug('RealtimeEventsService', 'Realtime events connection already started'); } } } @@ -186,6 +216,7 @@ export class RealtimeEventsService implements OnDestroy { private async disconnect() { if (this._hubConnection) { + Logger.logDebug('RealtimeEventsService', 'Stopping realtime events connection'); const connection = this._hubConnection; this._hubConnection = undefined; await connection.stop(); diff --git a/src/client/src/app/services/storage.ts b/src/client/src/app/services/storage.ts index d71511b..fc6b298 100644 --- a/src/client/src/app/services/storage.ts +++ b/src/client/src/app/services/storage.ts @@ -1,3 +1,4 @@ +import { Logger } from './logger.service'; import { User } from '../models/parsed-models'; const TOKEN_KEY = 'access_token_info'; @@ -8,9 +9,18 @@ function getLocalStorage(key: string): string | null { return localStorage.getItem(key); } function setLocalStorage(key: string, value: string | null) { + const oldValue = localStorage.getItem(key); + if (oldValue === value) return; if (value) { + Logger.logDebug('Storage', `Setting local storage key "${key}"`, { + from: localStorage.getItem(key), + to: value, + }); localStorage.setItem(key, value); } else { + Logger.logDebug('Storage', `Removing local storage key "${key}"`, { + from: localStorage.getItem(key), + }); localStorage.removeItem(key); } } @@ -50,3 +60,17 @@ export function getHasConfiguredPush(): boolean { export function setHasConfiguredPush(hasConfigured: boolean) { setLocalStorage(HAS_CONFIGURED_PUSH, hasConfigured ? 'true' : 'false'); } + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +const logLevelDefaults: { [L in LogLevel]: 'true' | 'false' } = { + debug: 'false', + info: 'true', + warn: 'true', + error: 'true', +}; +export function getLogLevelEnabled(level: LogLevel): boolean { + return (getLocalStorage(`log_level_${level}`) ?? logLevelDefaults[level]) === 'true'; +} +export function setLogLevelEnabled(level: LogLevel, enabled: boolean) { + setLocalStorage(`log_level_${level}`, enabled ? 'true' : 'false'); +} diff --git a/src/client/src/app/services/translate.service.ts b/src/client/src/app/services/translate.service.ts index 0e89190..60201af 100644 --- a/src/client/src/app/services/translate.service.ts +++ b/src/client/src/app/services/translate.service.ts @@ -106,9 +106,11 @@ export type TranslationsSignal = Signal & Readonly<{ [K in TranslateKeys]: Signal & { key: K }; }>; -function toTranslationsSignal(signal: Signal): TranslationsSignal { +function toTranslationsSignal>( + signal: Signal +): TranslationsSignal { return new Proxy(signal, { - get(target: any, prop) { + get(target: Signal & Record, prop) { if (typeof prop !== 'string' || isFunctionKey(prop)) { return target[prop]; } @@ -119,6 +121,12 @@ function toTranslationsSignal(signal: Signal): TranslationsSig }) as TranslationsSignal; } -function getDeepValue(obj: T, path: string[]): unknown { - return path.reduce((xs, x) => (xs && (xs as any)[x] ? (xs as any)[x] : null), obj); +function getDeepValue | undefined>( + obj: T, + path: string[] +): unknown { + return path.reduce | null | undefined>( + (xs, x) => (xs && xs[x] ? (xs[x] as Record) : null), + obj + ); } diff --git a/src/client/src/app/services/update.service.ts b/src/client/src/app/services/update.service.ts index 489b912..5be214a 100644 --- a/src/client/src/app/services/update.service.ts +++ b/src/client/src/app/services/update.service.ts @@ -1,8 +1,9 @@ -import { inject, Injectable } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { effect, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { SwUpdate } from '@angular/service-worker'; import { merge, filter, map, debounceTime } from 'rxjs'; +import { Logger } from './logger.service'; import { RealtimeEventsService } from './realtime-events.service'; import { onDocumentVisibilityChange$ } from '../utils/event.utils'; @@ -25,12 +26,30 @@ export class UpdateService { onDocumentVisibilityChange$().pipe(filter(isVisible => isVisible)), this._realtimeEventsService.onReconnected$ ) - .pipe(debounceTime(1000)) - .subscribe(() => { - console.info('Checking for updates...'); - this._swUpdate.checkForUpdate().then(x => console.info('Update check result:', x)); + .pipe(takeUntilDestroyed(), debounceTime(1000)) + .subscribe(async () => { + Logger.logDebug('UpdateService', 'Checking for updates...'); + const result = await this._swUpdate.checkForUpdate(); + Logger.logDebug('UpdateService', 'Update check result:', result); }); - this._swUpdate.unrecoverable.subscribe(() => location.reload()); + + this._swUpdate.unrecoverable.pipe(takeUntilDestroyed()).subscribe(() => { + Logger.logError( + 'UpdateService', + 'Unrecoverable error in service worker, reloading page...' + ); + location.reload(); + }); + + this._swUpdate.versionUpdates.pipe(takeUntilDestroyed()).subscribe(x => { + Logger.logDebug('UpdateService', 'Got version update event', x); + }); } + + effect(() => { + if (this.newVersionAvailable()) { + Logger.logInfo('UpdateService', 'New version available'); + } + }); } } diff --git a/src/client/src/app/services/web-push.service.ts b/src/client/src/app/services/web-push.service.ts index d1e2ea3..cf50167 100644 --- a/src/client/src/app/services/web-push.service.ts +++ b/src/client/src/app/services/web-push.service.ts @@ -4,6 +4,7 @@ import { SwPush } from '@angular/service-worker'; import { combineLatest, filter, first, startWith, pairwise, firstValueFrom, map } from 'rxjs'; import { AuthService } from './auth.service'; +import { Logger } from './logger.service'; import { getHasConfiguredPush, setHasConfiguredPush } from './storage'; import { TranslateService } from './translate.service'; import { WellKnownService } from './well-known.service'; @@ -34,7 +35,10 @@ export class WebPushService { : signal(false); constructor() { - if (!this.notificationsSupported) return; + if (!this.notificationsSupported) { + Logger.logInfo('WebPushService', 'Web push notifications are not supported'); + return; + } if (!getHasConfiguredPush()) { combineLatest([toObservable(this._authService.isAuthorized), this._swPush.subscription]) @@ -58,7 +62,15 @@ export class WebPushService { this._swPush.subscription, ]) .pipe(startWith([undefined, undefined, undefined]), pairwise(), takeUntilDestroyed()) - .subscribe(([[_oldUser, _oldLang, oldSub], [newUser, newLang, newSub]]) => { + .subscribe(([[oldUser, oldLang, oldSub], [newUser, newLang, newSub]]) => { + Logger.logDebug('WebPushService', 'User, lang, sub changed', { + oldUser, + oldLang, + oldSub, + newUser, + newLang, + newSub, + }); if (newSub && newLang && newUser && newUser.id !== 'admin') { this.registerSubscription(newLang, newSub); } else if (oldSub && !newSub) { @@ -77,18 +89,23 @@ export class WebPushService { let permission: NotificationPermission = Notification.permission; if (permission === 'default') { + Logger.logDebug('WebPushService', 'Requesting permission for push notifications'); permission = await Notification.requestPermission(); this.notificationsPermission.set(permission); } + Logger.logDebug('WebPushService', 'Permission for push notifications', permission); if (permission === 'granted') { const subscription = this._subscription(); if (subscription) { + Logger.logDebug('WebPushService', 'Already subscribed to push notifications', subscription); await this.registerSubscription(this._translateService.language(), subscription); setHasConfiguredPush(true); return true; } + Logger.logDebug('WebPushService', 'Getting VAPID key'); const { vapidPublicKey } = await firstValueFrom(this._wellKnownService.wellKnown$); + Logger.logDebug('WebPushService', 'Requesting subscription to push notifications'); await this._swPush.requestSubscription({ serverPublicKey: vapidPublicKey }); setHasConfiguredPush(true); return true; @@ -99,7 +116,6 @@ export class WebPushService { } public async disable(keepSubscription: boolean = false): Promise { - console.log('disable'); if (!this.notificationsSupported) return; const subscription = this._subscription(); @@ -108,12 +124,14 @@ export class WebPushService { if (keepSubscription) { await this.unregisterSubscription(subscription); } else { + Logger.logDebug('WebPushService', 'Unsubscribing from push notifications', subscription); await this._swPush.unsubscribe(); setHasConfiguredPush(true); } } private async registerSubscription(newLang: string, newSub: PushSubscription) { + Logger.logDebug('WebPushService', 'Registering subscription', newSub); await this._notificationsService.subscribeToNotifications({ body: { lang: newLang, @@ -125,6 +143,7 @@ export class WebPushService { } private async unregisterSubscription(oldSub: PushSubscription) { + Logger.logDebug('WebPushService', 'Unregistering subscription', oldSub); await this._notificationsService.unsubscribeFromNotifications({ body: { endpoint: oldSub.endpoint }, }); diff --git a/src/client/src/app/services/well-known.service.ts b/src/client/src/app/services/well-known.service.ts index 69d4352..a62bf4b 100644 --- a/src/client/src/app/services/well-known.service.ts +++ b/src/client/src/app/services/well-known.service.ts @@ -1,9 +1,9 @@ import { inject, Injectable } from '@angular/core'; -import { ReplaySubject } from 'rxjs'; +import { defer, of, ReplaySubject, retry, switchMap, throwError } from 'rxjs'; +import { Logger } from './logger.service'; import { ApiGetWellKnownConfigurationResponse } from '../api/models'; import { WellKnownService as WellKnownApiService } from '../api/services'; -import { assertBody } from '../utils/http.utils'; @Injectable({ providedIn: 'root' }) export class WellKnownService { @@ -12,11 +12,20 @@ export class WellKnownService { public wellKnown$ = new ReplaySubject(1); constructor() { - this._api.getWellKnownConfiguration().then(response => { - if (response.ok) { - this.wellKnown$.next(assertBody(response)); - } - this.wellKnown$.error(response); - }); + defer(() => this._api.getWellKnownConfiguration()) + .pipe( + switchMap(response => + response.ok && response.body ? of(response.body) : throwError(() => response) + ), + retry({ + delay: error => { + Logger.logError('WellKnownService', 'Error getting well-known configuration', { + error, + }); + return of(5000); + }, + }) + ) + .subscribe(this.wellKnown$); } } diff --git a/src/client/src/app/utils/common.utils.ts b/src/client/src/app/utils/common.utils.ts index 0efdd50..8a7de0a 100644 --- a/src/client/src/app/utils/common.utils.ts +++ b/src/client/src/app/utils/common.utils.ts @@ -2,11 +2,17 @@ export function notNullish(value: T): value is NonNullable { return value != null; } +export function isNullish(value: T | null | undefined): value is null | undefined { + return value === null || value === undefined; +} + export type RemoveUndefinedProperties = { [K in keyof T as undefined extends T[K] ? never : K]: T[K]; } & { [K in keyof T as undefined extends T[K] ? K : never]?: T[K] }; export function removeUndefinedProperties(obj: T): RemoveUndefinedProperties { - return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as any; + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined) + ) as RemoveUndefinedProperties; } export function isEmptyObject( @@ -35,19 +41,27 @@ export function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)); } -export function ifTruthy(value: T, fn: (value: NonNullable) => R, elseValue: R): R; -export function ifTruthy( +type NonFunction = object | string | number | boolean | symbol | null | undefined; +export function ifTruthy( + value: T, + fn: (value: NonNullable) => R, + elseValue: R +): R; +export function ifTruthy( value: T, fn: (value: NonNullable) => R, elseFn: (value: T) => R ): R; -export function ifTruthy(value: T, fn: (value: NonNullable) => R): R | undefined; -export function ifTruthy( +export function ifTruthy( + value: T, + fn: (value: NonNullable) => R +): R | undefined; +export function ifTruthy( value: T, fn: (value: NonNullable) => R, elseFn?: ((value: T) => R) | R ): R | undefined { if (value) return fn(value); - if (typeof elseFn === 'function') return (elseFn as any)(value); + if (typeof elseFn === 'function') return elseFn(value); return elseFn; } diff --git a/src/client/src/app/utils/event.utils.ts b/src/client/src/app/utils/event.utils.ts index 7cdc5c5..cb6f3c6 100644 --- a/src/client/src/app/utils/event.utils.ts +++ b/src/client/src/app/utils/event.utils.ts @@ -1,20 +1,20 @@ import { EMPTY, fromEvent, map } from 'rxjs'; export function onDocumentVisibilityChange$() { - let hidden: string; + let hidden: keyof Document; let visibilityChange: string; if ('hidden' in document) { hidden = 'hidden'; visibilityChange = 'visibilitychange'; } else if ('msHidden' in document) { - hidden = 'msHidden'; + hidden = 'msHidden' as keyof Document; visibilityChange = 'msvisibilitychange'; } else if ('webkitHidden' in document) { - hidden = 'webkitHidden'; + hidden = 'webkitHidden' as keyof Document; visibilityChange = 'webkitvisibilitychange'; } else { return EMPTY; } - return fromEvent(document, visibilityChange).pipe(map(() => !(document as any)[hidden])); + return fromEvent(document, visibilityChange).pipe(map(() => !document[hidden])); } diff --git a/src/client/src/main.ts b/src/client/src/main.ts index bd95c7d..7ab34af 100644 --- a/src/client/src/main.ts +++ b/src/client/src/main.ts @@ -3,5 +3,8 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import('./app/components/app/app.component').then(({ AppComponent }) => - bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)) + bootstrapApplication(AppComponent, appConfig).catch(err => { + // eslint-disable-next-line no-console + console.error(err); + }) );