schedule search and optimization #6

Merged
Piet merged 2 commits from schedule into main 2025-11-05 10:59:56 +00:00
17 changed files with 204 additions and 19 deletions

View File

@@ -1,9 +1,10 @@
import { CommonModule } from '@angular/common';
import { NgModule, provideBrowserGlobalErrorListeners } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { AppRoutingModule } from './app-routing-module';
import { App } from './app';
@@ -12,7 +13,11 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconButton } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { HeaderComponent } from './header/header.component';
import { HomeComponent } from './home/home.component';
@@ -34,8 +39,10 @@ import { Header2Component } from './header-2/header-2.component';
import { TheaterOverlayComponent } from './theater-overlay/theater-overlay.component';
import { SeatComponent } from './seat/seat.component';
import { SeatRowComponent } from './seat-row/seat-row.component';
import {MatIconButton} from '@angular/material/button';
import { TheaterLayoutComponent } from './theater-layout/theater-layout.component';
import { MovieSearchComponent } from './movie-search/movie-search.component';
import { ScheduleHeaderComponent } from './schedule-header/schedule-header.component';
import { MovieScheduleNoSearchResultComponent } from './movie-schedule-no-search-result/movie-schedule-no-search-result.component';
@NgModule({
@@ -60,11 +67,15 @@ import { TheaterLayoutComponent } from './theater-layout/theater-layout.componen
TheaterOverlayComponent,
SeatComponent,
SeatRowComponent,
TheaterLayoutComponent
TheaterLayoutComponent,
MovieSearchComponent,
ScheduleHeaderComponent,
MovieScheduleNoSearchResultComponent
],
imports: [
AppRoutingModule,
BrowserModule,
ReactiveFormsModule,
CommonModule,
FormsModule,
MatIconModule,
@@ -72,7 +83,11 @@ import { TheaterLayoutComponent } from './theater-layout/theater-layout.componen
MatToolbarModule,
MatProgressBarModule,
MatSnackBarModule,
MatIconButton
MatAutocompleteModule,
MatInputModule,
MatFormFieldModule,
MatIconButton,
MatDividerModule
],
providers: [
provideBrowserGlobalErrorListeners(),

View File

@@ -6,7 +6,7 @@
</a>
<div class="absolute left-1/2 transform -translate-x-1/2 text-center">
<h2 class="text-3xl font-bold animate-fadeUp-delay bg-gradient-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
<h2 class="text-3xl font-bold bg-gradient-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
Absolut war gestern, Bewegung ist heute!
</h2>
</div>

View File

@@ -1,4 +1,4 @@
import {Kinosaal, Sitzplatz, Vorstellung} from '@infinimotion/model-frontend';
import { Kinosaal, Sitzplatz, Vorstellung, Film } from '@infinimotion/model-frontend';
import { HttpClient } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import { Observable } from "rxjs";
@@ -51,6 +51,11 @@ export class HttpService {
return this.http.get<Vorstellung>(`${this.baseUrl}vorstellung/${id}`);
}
/* POST /api/vorstellung/filter */
getPerformaceByFilter(filter: string[]): Observable<Vorstellung[]> {
return this.http.post<Vorstellung[]>(`${this.baseUrl}vorstellung/filter`, filter);
}
/* POST /api/vorstellung */
addPerformace(vorstellung: Omit<Vorstellung, 'id'>): Observable<Vorstellung> {
return this.http.post<Vorstellung>(`${this.baseUrl}vorstellung`, vorstellung);
@@ -67,6 +72,13 @@ export class HttpService {
}
/* Film APIs */
/* GET /api/film */
getMovies(): Observable<Film[]> {
return this.http.get<Film[]>(`${this.baseUrl}film`);
}
/* Show-Seats APIs*/

View File

@@ -0,0 +1,5 @@
<div class="flex flex-col items-center justify-center py-12 text-gray-500 h-100 m-auto">
<mat-icon class="text-6xl mb-4 opacity-50" style="font-size: 50px; width: 50px; height: 50px;">search_off</mat-icon>
<p class="text-lg">Keine Vorstellungen gefunden</p>
<p class="text-sm">Für '{{ search() }}' finden am {{ date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric'} )}} kein Vorstellunge statt.</p>
</div>

View File

@@ -0,0 +1,12 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-movie-schedule-no-search-result',
standalone: false,
templateUrl: './movie-schedule-no-search-result.component.html',
styleUrl: './movie-schedule-no-search-result.component.css'
})
export class MovieScheduleNoSearchResultComponent {
search = input.required<string>();
date = input.required<Date>();
}

View File

@@ -0,0 +1,16 @@
<form class="movie-search-form w-88">
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>Film suchen</mat-label>
<input class="w-full" type="text" matInput [formControl]="searchControl" [matAutocomplete]="auto" (click)="searchControl.setValue('')">
<!-- @if (searchControl.hasError('filmNotFound')) { -->
<!-- <mat-error>Film existiert nicht</mat-error> -->
<!-- } -->
<mat-autocomplete autoActiveFirstOption #auto="matAutocomplete">
@for (option of filteredOptions | async; track option) {
<mat-option [value]="option">{{option}}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</form>

View File

@@ -0,0 +1,62 @@
import { Component, inject, OnInit, output, ViewEncapsulation } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { catchError, map, startWith, tap } from 'rxjs/operators';
import { HttpService } from '../http.service';
@Component({
selector: 'app-movie-search',
standalone: false,
templateUrl: './movie-search.component.html',
styleUrl: './movie-search.component.css',
encapsulation: ViewEncapsulation.None
})
export class MovieSearchComponent implements OnInit {
movieSearchResult = output<string>();
options: string[] = [];
filteredOptions: Observable<string[]> = new Observable();
searchControl = new FormControl('', (control) => {
if (!control.value) return null;
const value = control.value.toLowerCase();
const found = this.options.some(option =>
option.toLowerCase().includes(value)
);
return found ? null : { filmNotFound: true };
});
private http = inject(HttpService)
ngOnInit() {
this.loadMovies();
this.filteredOptions = this.searchControl.valueChanges.pipe(
startWith(''),
tap(value => this.movieSearchResult.emit(value || '')),
map(value => this._filter(value || ''))
);
}
private _filter(value: string): string[] {
const filterValue = value.toLowerCase();
return this.options.filter(option =>
option.toLowerCase().includes(filterValue)
);
}
private loadMovies() {
this.http.getMovies().pipe(
tap(movies => {
this.options = movies
.map(movie => movie.title)
.sort();
}),
catchError(err => {
console.error('Fehler beim Laden der Filme', err);
return of([]);
})
).subscribe();
}
}

View File

@@ -6,7 +6,7 @@ nav {
}
.navbar {
width: 200px;
width: 250px;
background-color: white;
padding: 5px;
transition: color .2s;

View File

@@ -7,7 +7,7 @@
<a [routerLink]="[item.path]"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
class="text-xl font-bold flex justify-center hover:bg-gray-200 gradient-text rounded-sm">
class="text-2xl pl-3 hover:bg-gray-200 gradient-text rounded-sm">
{{ item.label }}
</a>
</div>

View File

@@ -12,7 +12,7 @@ export class NavbarComponent {
*/
navItems:{label:string, path:string}[] = [
{label: 'Schedule', path: '/schedule'},
{label: 'Programm', path: '/schedule'},
{label: 'Kinosaal-test', path: '/theater-overlay'},
]
}

View File

@@ -0,0 +1,6 @@
.schedule-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}

View File

@@ -0,0 +1,10 @@
<div class="schedule-header px-4 py-4 duration-1000">
<div class="flex items-center">
<mat-icon style="font-size: 35px; width: 35px; height: 35px; opacity: 50%;">event</mat-icon>
<p class="text-2xl font-medium pl-2 bg-gradient-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
Programmübersicht
</p>
</div>
<app-movie-search (movieSearchResult)="movieSearchResult.emit($event)"></app-movie-search>
</div>
<mat-divider></mat-divider>

View File

@@ -0,0 +1,11 @@
import { Component, output } from '@angular/core';
@Component({
selector: 'app-schedule-header',
standalone: false,
templateUrl: './schedule-header.component.html',
styleUrl: './schedule-header.component.css'
})
export class ScheduleHeaderComponent {
movieSearchResult = output<string>();
}

View File

@@ -1,9 +1,17 @@
<app-schedule-header (movieSearchResult)="movieSearchResult = $event"></app-schedule-header>
<mat-tab-group mat-stretch-tabs>
@for (dateInfo of dates; track dateInfo.date; let i = $index) {
<mat-tab [label]="dateInfo.label">
@if (getMovieCount(i) > 0) {
@for (group of dateInfo.performances; track group.movie.id) {
<app-movie-schedule-info [movieGroup]="group"></app-movie-schedule-info>
@if (hasSearchResults(i)) {
@for (group of dateInfo.performances; track group.movie.id) {
@if (group.movie.title.toLowerCase().includes(movieSearchResult.toLowerCase())) {
<app-movie-schedule-info [movieGroup]="group"></app-movie-schedule-info>
}
}
} @else {
<app-movie-schedule-no-search-result [search]="movieSearchResult" [date]="dates[i].date" ></app-movie-schedule-no-search-result>
}
} @else {
<app-movie-schedule-empty></app-movie-schedule-empty>

View File

@@ -16,20 +16,32 @@ export class ScheduleComponent implements OnInit {
dates: { label: string; date: Date; performances: MovieGroup[] }[] = [];
performaces: Vorstellung[] = [];
movieSearchResult: string = '';
private readonly bookableDays: number = 14;
private http = inject(HttpService);
private loading = inject(LoadingService)
constructor() {
this.generateDates();
this.generateDates(this.bookableDays);
}
ngOnInit() {
this.loadPerformances();
this.loadPerformances(this.bookableDays);
}
generateDates() {
hasSearchResults(dateIndex: number): boolean {
if (!this.movieSearchResult) return true;
return this.dates[dateIndex].performances.some(group =>
group.movie.title.toLowerCase().includes(this.movieSearchResult.toLowerCase())
);
}
generateDates(bookableDays: number) {
const today = new Date();
for (let i = 0; i < 14; i++) {
for (let i = 0; i < bookableDays; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
@@ -46,9 +58,10 @@ export class ScheduleComponent implements OnInit {
}
}
loadPerformances() {
loadPerformances(bookableDays: number) {
this.loading.show();
this.http.getPerformaces().pipe(
const filter = this.generateDateFilter(bookableDays);
this.http.getPerformaceByFilter(filter).pipe(
map(data => Array.isArray(data) ? data : [data]),
tap(performaces => {
this.performaces = performaces;
@@ -57,12 +70,27 @@ export class ScheduleComponent implements OnInit {
}),
catchError(err => {
this.loading.showError(err);
console.error('Fehler beim Laden der Vorstellung', err);
console.error('Fehler beim Laden der Vorstellungen', err);
return of([]);
})
).subscribe();
}
private generateDateFilter(bookableDays: number): string[] {
const startDate = new Date();
const endDate = new Date();
endDate.setDate(startDate.getDate() + bookableDays - 1);
const startStr = startDate.toISOString().split('T')[0] + 'T00:00:00';
const endStr = endDate.toISOString().split('T')[0] + 'T23:59:59';
return [
`ge;start;date;${startStr}`,
`le;start;date;${endStr}`,
];
}
assignPerformancesToDates() {
// Gruppieren nach Datum