From b619d744c1d9709674f127c897baa22994cf0c8b Mon Sep 17 00:00:00 2001 From: Piet Ostendorp Date: Wed, 19 Nov 2025 10:59:34 +0100 Subject: [PATCH] Add inactivity timeout and polling control to seat overlay Introduces user inactivity tracking and timeout for seat polling in TheaterOverlayComponent. Polling is paused after 2 minutes of inactivity or when the user advances past step 1, with a snackbar notification and resume option. Refactors polling logic, adds step change event to OrderComponent, and updates custom theme for snackbar styling. --- src/app/order/order.component.ts | 7 +- .../theater-overlay.component.html | 2 +- .../theater-overlay.component.ts | 156 ++++++++++++++++-- src/custom-theme.scss | 18 +- 4 files changed, 161 insertions(+), 22 deletions(-) diff --git a/src/app/order/order.component.ts b/src/app/order/order.component.ts index 84aa2d0..04717de 100644 --- a/src/app/order/order.component.ts +++ b/src/app/order/order.component.ts @@ -1,7 +1,7 @@ import { SelectedSeatsService } from './../selected-seats.service'; import { LoadingService } from './../loading.service'; import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; -import { Component, computed, DestroyRef, inject, input, signal } from '@angular/core'; +import { Component, computed, DestroyRef, EventEmitter, inject, input, output, Output, signal, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { StepperSelectionEvent } from '@angular/cdk/stepper'; import { HttpService } from '../http.service'; @@ -29,12 +29,15 @@ export class OrderComponent { private fb = inject(FormBuilder); private httpService = inject(HttpService); private destroyRef = inject(DestroyRef); + readonly loadingService = inject(LoadingService); readonly selectedSeatsService = inject(SelectedSeatsService); performance = input(); seatCategories = input.required(); + stepChanged = output(); + paymentForm!: FormGroup; dataForm!: FormGroup; @@ -107,6 +110,8 @@ export class OrderComponent { onStepChange(event: StepperSelectionEvent) { this.submitted.set(false); this.selectedSeatsService.setSeatSelectable(event.selectedIndex === 0); + + this.stepChanged.emit(event.selectedIndex); } reservationClicked() { diff --git a/src/app/theater-overlay/theater-overlay.component.html b/src/app/theater-overlay/theater-overlay.component.html index 78bd5b8..d255376 100644 --- a/src/app/theater-overlay/theater-overlay.component.html +++ b/src/app/theater-overlay/theater-overlay.component.html @@ -18,6 +18,6 @@ - + diff --git a/src/app/theater-overlay/theater-overlay.component.ts b/src/app/theater-overlay/theater-overlay.component.ts index f588044..961fb45 100644 --- a/src/app/theater-overlay/theater-overlay.component.ts +++ b/src/app/theater-overlay/theater-overlay.component.ts @@ -1,14 +1,17 @@ -import {Component, DestroyRef, inject, OnDestroy, OnInit, signal} from '@angular/core'; -import {HttpService} from '../http.service'; -import {LoadingService} from '../loading.service'; -import {catchError, filter, forkJoin, interval, of, startWith, switchMap, tap} from 'rxjs'; -import {Sitzkategorie, Sitzplatz, Vorstellung} from '@infinimotion/model-frontend'; -import {TheaterSeatState} from '../model/theater-seat-state.model'; -import {ActivatedRoute} from '@angular/router'; -import {SelectedSeatsService} from '../selected-seats.service'; +import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { HttpService } from '../http.service'; +import { LoadingService } from '../loading.service'; +import { catchError, filter, forkJoin, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs'; +import { Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; +import { TheaterSeatState } from '../model/theater-seat-state.model'; +import { ActivatedRoute } from '@angular/router'; +import { SelectedSeatsService } from '../selected-seats.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; -const POLLING_INTERVAL_MS = 5000; +const POLLING_INTERVAL_MS = 5 * 1000; +const INACTIVITY_TIMEOUT_MS = 2 * 60 * 1000; @Component({ selector: 'app-theater-overlay', @@ -21,6 +24,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private destroyRef = inject(DestroyRef); private selectedSeatService = inject(SelectedSeatsService); + private dialog = inject(MatDialog); readonly loading = inject(LoadingService); @@ -29,9 +33,16 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]); performance: Vorstellung | undefined; seatCategories: Sitzkategorie[] = []; + snackBarRef: MatSnackBarRef | undefined private isPollingEnabled = signal(true); private isInitialLoad = signal(true); + private lastActivityTimestamp = signal(Date.now()); + private inactivityTimeoutReached = signal(false); + private isRequestInProgress = signal(false); + private isStepTwoOrHigher = signal(false); + + constructor(private snackBar: MatSnackBar) {} ngOnInit() { this.showId = Number(this.route.snapshot.paramMap.get('performanceId')!); @@ -39,18 +50,107 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.selectedSeatService.clearSelection(); this.selectedSeatService.setSeatSelectable(true); + this.setupActivityTracking(); this.startAutoRefresh(); + this.startInactivityCheck(); } ngOnDestroy() { this.isPollingEnabled.set(false); + console.info('[TheaterOverlay] Stopped auto-refresh polling'); + + if(this.snackBarRef) { + this.snackBar.dismiss(); + } + } + + private setupActivityTracking() { + console.info('[TheaterOverlay] Setting up activity tracking'); + const events$ = merge( + fromEvent(document, 'mousemove'), + fromEvent(document, 'mousedown'), + fromEvent(document, 'keypress'), + fromEvent(document, 'scroll'), + fromEvent(document, 'touchstart') + ); + + events$.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.lastActivityTimestamp.set(Date.now()); + }); + + fromEvent(document, 'visibilitychange').pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + if (!this.isStepTwoOrHigher()) { + if (document.hidden) { + console.info('[TheaterOverlay] Tab hidden - pausing polling'); + this.pausePolling(); + } else { + // Nur in Schritt 1 reaktivieren + if (!this.inactivityTimeoutReached()) { + console.info('[TheaterOverlay] Tab visible - resumed polling'); + this.isPollingEnabled.set(true); + this.refreshSeats(); + } + } + } + }); + } + + private startInactivityCheck() { + interval(10000).pipe( + filter(() => this.isPollingEnabled()), + filter(() => !this.isStepTwoOrHigher()), // Kein Timeout ab Schritt 2 + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + const inactiveDuration = Date.now() - this.lastActivityTimestamp(); + + if (inactiveDuration >= INACTIVITY_TIMEOUT_MS && !this.inactivityTimeoutReached()) { + console.info('[TheaterOverlay] Inactivity timeout reached'); + this.handleInactivityTimeout(); + } + }); + } + + private handleInactivityTimeout() { + this.inactivityTimeoutReached.set(true); + this.pausePolling(); + this.showInactivitySnackBar(); + } + + private showInactivitySnackBar() { + + this.snackBarRef = this.snackBar.open( + 'Sitzplatzaktuallisierung wegen Inaktivität gestoppt.', + 'Fortsetzen', + { + duration: 0, + panelClass: ['timeout-snackbar'], + horizontalPosition: 'center', + verticalPosition: 'bottom' + } + ); + + this.snackBarRef.afterDismissed().subscribe(() => { + this.lastActivityTimestamp.set(Date.now()); + this.inactivityTimeoutReached.set(false); + this.resumePolling(); + this.refreshSeats(); + }); } private startAutoRefresh() { + console.info('[TheaterOverlay] Starting auto-refresh polling'); interval(POLLING_INTERVAL_MS).pipe( - startWith(0), + startWith(POLLING_INTERVAL_MS), filter(() => this.isPollingEnabled()), filter(() => !this.selectedSeatService.committed()), + filter(() => !document.hidden), // Nicht pollen, wenn Tab nicht sichtbar + filter(() => !this.inactivityTimeoutReached()), // Nicht pollen nach Timeout + filter(() => !this.isRequestInProgress()), // Nicht pollen, wenn Request läuft + filter(() => !this.isStepTwoOrHigher()), // Nicht pollen ab Schritt 2 switchMap(() => this.loadPerformanceAndSeats()), takeUntilDestroyed(this.destroyRef) ).subscribe(); @@ -61,6 +161,8 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.loading.show(); } + this.isRequestInProgress.set(true); + return forkJoin({ performance: this.http.getPerformaceById(this.showId), seats: this.http.getSeatsByShowId(this.showId) @@ -73,6 +175,8 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.loading.hide(); this.isInitialLoad.set(false); } + + this.isRequestInProgress.set(false); }), catchError(err => { if (this.isInitialLoad()) { @@ -86,12 +190,14 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.isInitialLoad.set(false); } + this.isRequestInProgress.set(false); + return of({ performance: null, seats: { seats: [], reserved: [], booked: [] } }); }) ); } - converter(resp: { seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }): { + private converter(resp: { seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }): { seat: Sitzplatz | null, state: TheaterSeatState | null }[][] { @@ -163,16 +269,36 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { return filledRows; } - refreshSeats(): void { + private refreshSeats(): void { + console.info('[TheaterOverlay] Manual refresh triggered'); this.loadPerformanceAndSeats().subscribe(); } - pausePolling(): void { //TODO: Ab Stepper Schritt 2 Polling pausieren + private pausePolling(): void { + console.info('[TheaterOverlay] Polling paused'); this.isPollingEnabled.set(false); } - resumePolling(): void { - this.isPollingEnabled.set(true); + private resumePolling(): void { + console.info('[TheaterOverlay] Resume polling attempted'); + if (!this.inactivityTimeoutReached() && !this.isStepTwoOrHigher()) { + this.isPollingEnabled.set(true); + } + } + + setStepTwoOrHigher(isStep2OrHigher: boolean): void { + this.isStepTwoOrHigher.set(isStep2OrHigher); + + if (isStep2OrHigher) { + console.info('[TheaterOverlay] Moving to step 2+ - disabling polling'); + this.pausePolling(); + } else { + console.info('[TheaterOverlay] Back to step 1 - reactivating polling'); + this.lastActivityTimestamp.set(Date.now()); + this.inactivityTimeoutReached.set(false); + this.isPollingEnabled.set(true); + this.refreshSeats(); + } } } diff --git a/src/custom-theme.scss b/src/custom-theme.scss index 6eddae9..9862129 100644 --- a/src/custom-theme.scss +++ b/src/custom-theme.scss @@ -42,17 +42,25 @@ html.dark { background: linear-gradient(to right, #6366f1, #db2777) !important; } -@include mat.snack-bar-overrides(( - container-color: red, -)); + +.error-snackbar { + @include mat.snack-bar-overrides(( + container-color: red, + )); +} +.timeout-snackbar { + @include mat.snack-bar-overrides(( + container-color: #ff9900 + )); +} -.error-snackbar .mat-mdc-snack-bar-label { +.mat-mdc-snack-bar-label { color: white !important; } -.error-snackbar .mat-mdc-button { +.mat-mdc-button { color: white !important; }