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:
@@ -1,7 +1,7 @@
|
|||||||
import { SelectedSeatsService } from './../selected-seats.service';
|
import { SelectedSeatsService } from './../selected-seats.service';
|
||||||
import { LoadingService } from './../loading.service';
|
import { LoadingService } from './../loading.service';
|
||||||
import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
|
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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { StepperSelectionEvent } from '@angular/cdk/stepper';
|
import { StepperSelectionEvent } from '@angular/cdk/stepper';
|
||||||
import { HttpService } from '../http.service';
|
import { HttpService } from '../http.service';
|
||||||
@@ -29,12 +29,15 @@ export class OrderComponent {
|
|||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
private httpService = inject(HttpService);
|
private httpService = inject(HttpService);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
readonly loadingService = inject(LoadingService);
|
readonly loadingService = inject(LoadingService);
|
||||||
readonly selectedSeatsService = inject(SelectedSeatsService);
|
readonly selectedSeatsService = inject(SelectedSeatsService);
|
||||||
|
|
||||||
performance = input<Vorstellung>();
|
performance = input<Vorstellung>();
|
||||||
seatCategories = input.required<Sitzkategorie[]>();
|
seatCategories = input.required<Sitzkategorie[]>();
|
||||||
|
|
||||||
|
stepChanged = output<number>();
|
||||||
|
|
||||||
paymentForm!: FormGroup;
|
paymentForm!: FormGroup;
|
||||||
dataForm!: FormGroup;
|
dataForm!: FormGroup;
|
||||||
|
|
||||||
@@ -107,6 +110,8 @@ export class OrderComponent {
|
|||||||
onStepChange(event: StepperSelectionEvent) {
|
onStepChange(event: StepperSelectionEvent) {
|
||||||
this.submitted.set(false);
|
this.submitted.set(false);
|
||||||
this.selectedSeatsService.setSeatSelectable(event.selectedIndex === 0);
|
this.selectedSeatsService.setSeatSelectable(event.selectedIndex === 0);
|
||||||
|
|
||||||
|
this.stepChanged.emit(event.selectedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
reservationClicked() {
|
reservationClicked() {
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import {Component, DestroyRef, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||||
import {HttpService} from '../http.service';
|
import { HttpService } from '../http.service';
|
||||||
import {LoadingService} from '../loading.service';
|
import { LoadingService } from '../loading.service';
|
||||||
import {catchError, filter, forkJoin, interval, of, startWith, switchMap, tap} from 'rxjs';
|
import { catchError, filter, forkJoin, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs';
|
||||||
import {Sitzkategorie, Sitzplatz, Vorstellung} from '@infinimotion/model-frontend';
|
import { Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
|
||||||
import {TheaterSeatState} from '../model/theater-seat-state.model';
|
import { TheaterSeatState } from '../model/theater-seat-state.model';
|
||||||
import {ActivatedRoute} from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import {SelectedSeatsService} from '../selected-seats.service';
|
import { SelectedSeatsService } from '../selected-seats.service';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
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({
|
@Component({
|
||||||
selector: 'app-theater-overlay',
|
selector: 'app-theater-overlay',
|
||||||
@@ -21,6 +24,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private selectedSeatService = inject(SelectedSeatsService);
|
private selectedSeatService = inject(SelectedSeatsService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
readonly loading = inject(LoadingService);
|
readonly loading = inject(LoadingService);
|
||||||
|
|
||||||
@@ -29,9 +33,16 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]);
|
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]);
|
||||||
performance: Vorstellung | undefined;
|
performance: Vorstellung | undefined;
|
||||||
seatCategories: Sitzkategorie[] = [];
|
seatCategories: Sitzkategorie[] = [];
|
||||||
|
snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined
|
||||||
|
|
||||||
private isPollingEnabled = signal(true);
|
private isPollingEnabled = signal(true);
|
||||||
private isInitialLoad = 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() {
|
ngOnInit() {
|
||||||
this.showId = Number(this.route.snapshot.paramMap.get('performanceId')!);
|
this.showId = Number(this.route.snapshot.paramMap.get('performanceId')!);
|
||||||
@@ -39,18 +50,107 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
this.selectedSeatService.clearSelection();
|
this.selectedSeatService.clearSelection();
|
||||||
this.selectedSeatService.setSeatSelectable(true);
|
this.selectedSeatService.setSeatSelectable(true);
|
||||||
|
|
||||||
|
this.setupActivityTracking();
|
||||||
this.startAutoRefresh();
|
this.startAutoRefresh();
|
||||||
|
this.startInactivityCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.isPollingEnabled.set(false);
|
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() {
|
private startAutoRefresh() {
|
||||||
|
console.info('[TheaterOverlay] Starting auto-refresh polling');
|
||||||
interval(POLLING_INTERVAL_MS).pipe(
|
interval(POLLING_INTERVAL_MS).pipe(
|
||||||
startWith(0),
|
startWith(POLLING_INTERVAL_MS),
|
||||||
filter(() => this.isPollingEnabled()),
|
filter(() => this.isPollingEnabled()),
|
||||||
filter(() => !this.selectedSeatService.committed()),
|
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()),
|
switchMap(() => this.loadPerformanceAndSeats()),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe();
|
).subscribe();
|
||||||
@@ -61,6 +161,8 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
this.loading.show();
|
this.loading.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isRequestInProgress.set(true);
|
||||||
|
|
||||||
return forkJoin({
|
return forkJoin({
|
||||||
performance: this.http.getPerformaceById(this.showId),
|
performance: this.http.getPerformaceById(this.showId),
|
||||||
seats: this.http.getSeatsByShowId(this.showId)
|
seats: this.http.getSeatsByShowId(this.showId)
|
||||||
@@ -73,6 +175,8 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
this.loading.hide();
|
this.loading.hide();
|
||||||
this.isInitialLoad.set(false);
|
this.isInitialLoad.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isRequestInProgress.set(false);
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
if (this.isInitialLoad()) {
|
if (this.isInitialLoad()) {
|
||||||
@@ -86,12 +190,14 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
this.isInitialLoad.set(false);
|
this.isInitialLoad.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isRequestInProgress.set(false);
|
||||||
|
|
||||||
return of({ performance: null, seats: { seats: [], reserved: [], booked: [] } });
|
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,
|
seat: Sitzplatz | null,
|
||||||
state: TheaterSeatState | null
|
state: TheaterSeatState | null
|
||||||
}[][] {
|
}[][] {
|
||||||
@@ -163,16 +269,36 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
|
|||||||
return filledRows;
|
return filledRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshSeats(): void {
|
private refreshSeats(): void {
|
||||||
|
console.info('[TheaterOverlay] Manual refresh triggered');
|
||||||
this.loadPerformanceAndSeats().subscribe();
|
this.loadPerformanceAndSeats().subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
pausePolling(): void { //TODO: Ab Stepper Schritt 2 Polling pausieren
|
private pausePolling(): void {
|
||||||
|
console.info('[TheaterOverlay] Polling paused');
|
||||||
this.isPollingEnabled.set(false);
|
this.isPollingEnabled.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
resumePolling(): void {
|
private resumePolling(): void {
|
||||||
this.isPollingEnabled.set(true);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,17 +42,25 @@ html.dark {
|
|||||||
background: linear-gradient(to right, #6366f1, #db2777) !important;
|
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;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-snackbar .mat-mdc-button {
|
.mat-mdc-button {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user