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.
This commit is contained in:
2025-11-19 10:59:34 +01:00
parent f4eb700ab4
commit b619d744c1
4 changed files with 161 additions and 22 deletions

View File

@@ -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<Vorstellung>();
seatCategories = input.required<Sitzkategorie[]>();
stepChanged = output<number>();
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() {

View File

@@ -18,6 +18,6 @@
</div>
</div>
<app-order class="mt-10 mr-30 w-3/10" [performance]="performance" [seatCategories]="seatCategories"></app-order>
<app-order (stepChanged)="setStepTwoOrHigher($event >= 1)" class="mt-10 mr-30 w-3/10" [performance]="performance" [seatCategories]="seatCategories"></app-order>
</div>

View File

@@ -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<TextOnlySnackBar> | 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,17 +269,37 @@ 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 {
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();
}
}
}

View File

@@ -42,17 +42,25 @@ html.dark {
background: linear-gradient(to right, #6366f1, #db2777) !important;
}
@include mat.snack-bar-overrides((
.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;
}