Initial commit

This commit is contained in:
2026-09-17 21:45:57 +02:00
commit 9160e861e0
302 changed files with 31275 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
BookieClient/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

12
BookieClient/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
BookieClient/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
BookieClient/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
BookieClient/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
BookieClient/README.md Normal file
View File

@@ -0,0 +1,59 @@
# BookieClient
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.5.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

79
BookieClient/angular.json Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "b1796264-36dd-48d8-9c10-0ef06a610ff9"
},
"newProjectRoot": "projects",
"projects": {
"BookieClient": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2.5MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "BookieClient:build:production"
},
"development": {
"buildTarget": "BookieClient:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

8363
BookieClient/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
BookieClient/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "bookie-client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.13.0",
"dependencies": {
"@angular/animations": "^22.1.3",
"@angular/cdk": "^22.1.3",
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/material": "^22.1.3",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"chart.js": "^4.5.1",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.1.5",
"@angular/cli": "^22.1.5",
"@angular/compiler-cli": "^22.1.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

View File

@@ -0,0 +1,20 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { provideNativeDateAdapter } from '@angular/material/core';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideAnimationsAsync(),
provideNativeDateAdapter(),
{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
],
};

View File

@@ -0,0 +1,29 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
import { ShellComponent } from './layout/shell.component';
import { LoginComponent } from './features/auth/login.component';
import { DashboardComponent } from './features/dashboard/dashboard.component';
import { PreMatchComponent } from './features/pre-match/pre-match.component';
import { CrudPageComponent } from './features/crud/crud-page.component';
import { CRUD_CONFIGS } from './features/crud/crud-config';
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: '',
component: ShellComponent,
canActivate: [authGuard],
children: [
{ path: 'dashboard', component: DashboardComponent, title: 'Dashboard · Bookie' },
{ path: 'pre-match', component: PreMatchComponent, title: 'Pre-Match Reports · Bookie' },
{ path: 'leagues', component: CrudPageComponent, data: { config: CRUD_CONFIGS['leagues'] }, title: 'Leagues · Bookie' },
{ path: 'seasons', component: CrudPageComponent, data: { config: CRUD_CONFIGS['seasons'] }, title: 'Seasons · Bookie' },
{ path: 'teams', component: CrudPageComponent, data: { config: CRUD_CONFIGS['teams'] }, title: 'Teams · Bookie' },
{ path: 'players', component: CrudPageComponent, data: { config: CRUD_CONFIGS['players'] }, title: 'Players · Bookie' },
{ path: 'matches', component: CrudPageComponent, data: { config: CRUD_CONFIGS['matches'] }, title: 'Matches · Bookie' },
{ path: 'contracts', component: CrudPageComponent, data: { config: CRUD_CONFIGS['contracts'] }, title: 'Contracts · Bookie' },
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
],
},
{ path: '**', redirectTo: '' },
];

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet></router-outlet>',
})
export class App {}

View File

@@ -0,0 +1,19 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) return true;
router.navigate(['/login']);
return false;
};
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn() && auth.isAdmin()) return true;
router.navigate(['/dashboard']);
return false;
};

View File

@@ -0,0 +1,33 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const snack = inject(MatSnackBar);
const token = auth.token;
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
auth.logout();
router.navigate(['/login']);
} else if (err.status === 403) {
snack.open('You do not have permission to perform this action.', 'Dismiss', { duration: 4000 });
} else if (err.status === 0) {
snack.open('Cannot reach the API. Is the backend running?', 'Dismiss', { duration: 4000 });
} else if (err.status >= 500) {
snack.open('Server error. Please try again later.', 'Dismiss', { duration: 4000 });
}
return throwError(() => err);
}),
);
};

View File

@@ -0,0 +1,441 @@
// ---- Auth ----
export interface LoginRequest { username: string; password: string; }
export interface AuthResponse { token: string; username: string; role: string; expiresAt: string; }
// ---- Common ----
export interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface PagedQuery {
page?: number;
pageSize?: number;
search?: string;
sortBy?: string;
sortDesc?: boolean;
filters?: Record<string, string>;
}
export interface Lookup { id: number; name: string; }
// ---- Entities ----
export interface League {
leagueId: number;
name: string;
country: string;
tierLevel: number;
competitionType: string;
source?: string | null;
seasonCount?: number;
}
export interface Season {
seasonId: number;
leagueId: number;
leagueName?: string;
country?: string;
name: string;
startDate: string;
endDate?: string | null;
}
export interface Team {
teamId: number;
name: string;
shortName?: string | null;
city?: string | null;
stadium?: string | null;
foundedDate?: string | null;
}
export interface Player {
playerId: number;
fullName: string;
birthDate?: string | null;
nationality?: string | null;
primaryPosition?: string | null;
}
export interface Match {
matchId: number;
matchdayId: number;
matchdayNumber?: number;
seasonId?: number;
seasonName?: string;
leagueId?: number;
leagueName?: string;
country?: string;
homeTeamId: number;
homeTeamName?: string;
awayTeamId: number;
awayTeamName?: string;
kickoffAt: string;
stadium?: string | null;
referee?: string | null;
status: string;
homeScoreHt?: number | null;
awayScoreHt?: number | null;
homeScoreFt?: number | null;
awayScoreFt?: number | null;
dataSource?: string | null;
}
export interface PlayerContract {
contractId: number;
playerId: number;
playerName?: string;
teamId: number;
teamName?: string;
shirtNumber?: number | null;
startDate: string;
endDate?: string | null;
isCurrent?: boolean;
}
// ---- Dashboard ----
export interface DashboardSummary {
leagueCount: number;
seasonCount: number;
teamCount: number;
playerCount: number;
matchCount: number;
upcomingMatchesThisWeek: number;
finishedMatches: number;
nextMatches: UpcomingMatch[];
statusBreakdown: StatusBreakdown[];
}
export interface UpcomingMatch {
matchId: number;
kickoffAt: string;
homeTeamName: string;
awayTeamName: string;
leagueName: string;
country: string;
status: string;
}
export interface StatusBreakdown { status: string; count: number; }
// ---- Pre-Match Report ----
export interface PreMatchReport {
date: string;
matchCount: number;
seasonsBack: number;
groups: LeagueGroup[];
}
export interface LeagueGroup {
leagueId: number;
leagueName: string;
country: string;
seasonId: number;
seasonName: string;
matches: MatchReport[];
modelParams?: LeagueModelParam | null;
}
export interface MatchReport {
matchId: number;
kickoffAt: string;
status: string;
stadium?: string | null;
referee?: string | null;
homeTeamId: number;
homeTeamName: string;
awayTeamId: number;
awayTeamName: string;
homeScoreHt?: number | null;
awayScoreHt?: number | null;
homeScoreFt?: number | null;
awayScoreFt?: number | null;
hasScore: boolean;
seasonsIncluded: string[];
home: TeamReport;
away: TeamReport;
odds?: MatchOddsSummary | null;
extraOdds: MatchExtraOdds[];
}
export interface TeamReport {
teamId: number;
teamName: string;
matchesPlayed: number;
hasHistory: boolean;
goalsForAgainst: GoalsForAgainst;
seasonAverages: SeasonAverages;
form: FormResult[];
topScorers: TopScorer[];
prediction?: MatchTeamPrediction | null;
openAiPrediction?: MatchTeamOpenAiPrediction | null;
actual?: MatchTeamActual | null;
strength?: TeamStrength | null;
oddsApiName?: string | null;
}
export interface MatchTeamActual {
goals?: number | null;
shotsTotal?: number | null;
shotsOnTarget?: number | null;
corners?: number | null;
fouls?: number | null;
yellowCards: number;
redCards: number;
hasData: boolean;
}
export interface FormResult {
result: 'W' | 'D' | 'L' | string;
matchId: number;
kickoffAt: string;
opponentName: string;
isHome: boolean;
goalsFor: number;
goalsAgainst: number;
}
export interface GoalsForAgainst {
matchesPlayed: number;
avgGoalsFor?: number | null;
avgGoalsAgainst?: number | null;
hasData: boolean;
}
export interface SeasonAverages {
matchesPlayed: number;
possession?: number | null;
shotsTotal?: number | null;
shotsOnTarget?: number | null;
corners?: number | null;
fouls?: number | null;
offsides?: number | null;
yellowCards?: number | null;
redCards?: number | null;
hasData: boolean;
}
export interface TopScorer { playerName: string; goals: number; assists: number; }
export interface LeagueModelParam {
homeAdvantage: number;
rho: number;
avgHomeGoals?: number | null;
avgAwayGoals?: number | null;
matchesUsed: number;
halfLifeDays?: number | null;
trainedAt: string;
}
export interface TeamStrength {
logAttack: number;
logDefense: number;
attackFactor: number;
defenseFactor: number;
matchesUsed: number;
trainedAt: string;
}
export interface MatchOddsSummary {
bookmakerCount: number;
avgHomeOdds: number;
avgDrawOdds: number;
avgAwayOdds: number;
homeImplied: number;
drawImplied: number;
awayImplied: number;
avgTotalLine?: number | null;
avgOverOdds?: number | null;
avgUnderOdds?: number | null;
overImplied?: number | null;
underImplied?: number | null;
totalsBookmakerCount?: number;
latestFetchedAt: string;
}
export interface MatchExtraOdds {
market: string;
bookmaker: string;
line: number;
overOdds: number;
underOdds: number;
fetchedAt: string;
}
// ---- Match details (drill-down) ----
export interface MatchDetails {
matchId: number;
kickoffAt: string;
status: string;
stadium?: string | null;
referee?: string | null;
leagueName: string;
country: string;
seasonName: string;
matchdayNumber: number;
homeTeamId: number;
homeTeamName: string;
awayTeamId: number;
awayTeamName: string;
homeScoreHt?: number | null;
awayScoreHt?: number | null;
homeScoreFt?: number | null;
awayScoreFt?: number | null;
hasScore: boolean;
homeStats?: TeamMatchStats | null;
awayStats?: TeamMatchStats | null;
homePrediction?: MatchTeamPrediction | null;
awayPrediction?: MatchTeamPrediction | null;
hasPrediction: boolean;
homeOpenAiPrediction?: MatchTeamOpenAiPrediction | null;
awayOpenAiPrediction?: MatchTeamOpenAiPrediction | null;
hasOpenAiPrediction: boolean;
goals: GoalDetail[];
cards: CardDetail[];
penalties: PenaltyDetail[];
}
export interface MatchTeamPrediction {
predictedGoals?: number | null;
predictedShotsTotal?: number | null;
predictedShotsOnTarget?: number | null;
predictedCorners?: number | null;
predictedFouls?: number | null;
predictedYellowCards?: number | null;
modelTrainedAt: string;
halfLifeDays?: number | null;
predictedAt: string;
}
export interface MatchTeamOpenAiPrediction {
predictedGoals?: number | null;
predictedShotsOnTarget?: number | null;
predictedCorners?: number | null;
predictedFouls?: number | null;
predictedYellowCards?: number | null;
predictedRedCards?: number | null;
confidence?: string | null;
model?: string | null;
predictedAt: string;
}
export interface TeamMatchStats {
possessionPct?: number | null;
shotsTotal?: number | null;
shotsOnTarget?: number | null;
corners?: number | null;
fouls?: number | null;
offsides?: number | null;
yellowCards: number;
redCards: number;
}
export interface GoalDetail {
teamId: number; isHome: boolean; scorerName: string; assistName?: string | null;
minute: number; addedTime: number; goalType: string;
}
export interface CardDetail {
teamId: number; isHome: boolean; playerName: string; minute: number; cardType: string; reason?: string | null;
}
export interface PenaltyDetail {
teamId: number; isHome: boolean; playerName?: string | null; minute?: number | null; result: string;
}
// ---- Head-to-head ----
export interface HeadToHead {
teamAId: number;
teamAName: string;
teamBId: number;
teamBName: string;
totalMeetings: number;
teamAWins: number;
draws: number;
teamBWins: number;
teamAGoals: number;
teamBGoals: number;
hasData: boolean;
meetings: HeadToHeadMatch[];
}
export interface HeadToHeadMatch {
matchId: number;
kickoffAt: string;
status: string;
leagueName: string;
country: string;
seasonName: string;
homeTeamId: number;
homeTeamName: string;
awayTeamId: number;
awayTeamName: string;
homeScoreFt?: number | null;
awayScoreFt?: number | null;
}
// ---- Predictions (OpenAI-powered) ----
export interface MetricEstimate { estimate: number; low: number; high: number; }
export interface TeamPrediction {
goals: MetricEstimate;
shotsOnTarget: MetricEstimate;
corners: MetricEstimate;
fouls: MetricEstimate;
yellowCards: MetricEstimate;
redCards: MetricEstimate;
}
export interface MatchPrediction {
matchId: number;
homeTeam: string;
awayTeam: string;
league: string;
country?: string | null;
kickoffAt: string;
home: TeamPrediction;
away: TeamPrediction;
reasoning: string;
confidence: 'High' | 'Medium' | 'Low' | string;
flags: string[];
model: string;
}
// ---- Player & team stats ----
export interface PlayerStats {
playerId: number;
fullName: string;
nationality?: string | null;
primaryPosition?: string | null;
birthDate?: string | null;
currentTeam?: string | null;
totalGoals: number;
totalAssists: number;
matchesScored: number;
goalsOpenPlay: number;
goalsPenalty: number;
goalsOwn: number;
yellowCards: number;
redCards: number;
hasData: boolean;
seasons: PlayerSeasonStat[];
recentGoals: PlayerGoal[];
}
export interface PlayerSeasonStat { seasonName: string; leagueName: string; goals: number; assists: number; }
export interface PlayerGoal {
matchId: number; kickoffAt: string; homeTeamName: string; awayTeamName: string;
minute: number; addedTime: number; goalType: string;
}
export interface TeamStats {
teamId: number;
name: string;
city?: string | null;
stadium?: string | null;
played: number;
wins: number;
draws: number;
losses: number;
goalsFor: number;
goalsAgainst: number;
goalDifference: number;
winPct: number;
hasData: boolean;
averages: SeasonAverages;
form: FormResult[];
topScorers: TopScorer[];
seasons: TeamSeasonStat[];
recentMatches: TeamRecentMatch[];
}
export interface TeamSeasonStat {
seasonName: string; leagueName: string; played: number;
wins: number; draws: number; losses: number; goalsFor: number; goalsAgainst: number;
}
export interface TeamRecentMatch {
matchId: number; kickoffAt: string; leagueName: string; seasonName: string;
homeTeamName: string; awayTeamName: string; homeScoreFt?: number | null; awayScoreFt?: number | null;
}

View File

@@ -0,0 +1,113 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
DashboardSummary, HeadToHead, League, Lookup, Match, MatchDetails, MatchPrediction, PagedQuery,
PagedResult, Player, PlayerContract, PlayerStats, PreMatchReport, Season, Team, TeamStats,
} from '../models/models';
/** Turns a PagedQuery into HttpParams, omitting empty values. */
function toParams(query: PagedQuery): HttpParams {
let params = new HttpParams();
if (query.page != null) params = params.set('page', query.page);
if (query.pageSize != null) params = params.set('pageSize', query.pageSize);
if (query.search) params = params.set('search', query.search);
if (query.sortBy) params = params.set('sortBy', query.sortBy);
if (query.sortDesc != null) params = params.set('sortDesc', query.sortDesc);
if (query.filters) {
for (const [key, value] of Object.entries(query.filters)) {
if (value != null && value !== '') params = params.append('Filters', `${key}:${value}`);
}
}
return params;
}
/** Generic CRUD client for a REST resource exposed by the API. */
export class CrudClient<TRead, TWrite> {
constructor(private http: HttpClient, private resource: string) {}
private get base() { return `${environment.apiBaseUrl}/${this.resource}`; }
getPaged(query: PagedQuery): Observable<PagedResult<TRead>> {
return this.http.get<PagedResult<TRead>>(this.base, { params: toParams(query) });
}
getById(id: number): Observable<TRead> {
return this.http.get<TRead>(`${this.base}/${id}`);
}
create(dto: TWrite): Observable<TRead> {
return this.http.post<TRead>(this.base, dto);
}
update(id: number, dto: TWrite): Observable<TRead> {
return this.http.put<TRead>(`${this.base}/${id}`, dto);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${id}`);
}
}
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private get base() { return environment.apiBaseUrl; }
readonly leagues = new CrudClient<League, Partial<League>>(this.http, 'leagues');
readonly seasons = new CrudClient<Season, Partial<Season>>(this.http, 'seasons');
readonly teams = new CrudClient<Team, Partial<Team>>(this.http, 'teams');
readonly players = new CrudClient<Player, Partial<Player>>(this.http, 'players');
readonly matches = new CrudClient<Match, Partial<Match>>(this.http, 'matches');
readonly contracts = new CrudClient<PlayerContract, Partial<PlayerContract>>(this.http, 'player-contracts');
getDashboard(): Observable<DashboardSummary> {
return this.http.get<DashboardSummary>(`${this.base}/dashboard/summary`);
}
getPreMatchReport(date: string, seasonsBack = 0): Observable<PreMatchReport> {
return this.http.get<PreMatchReport>(`${this.base}/reports/pre-match`, {
params: new HttpParams().set('date', date).set('seasonsBack', seasonsBack),
});
}
getMatchDetails(matchId: number): Observable<MatchDetails> {
return this.http.get<MatchDetails>(`${this.base}/reports/match/${matchId}`);
}
getHeadToHead(teamAId: number, teamBId: number, beforeMatchId?: number): Observable<HeadToHead> {
let params = new HttpParams().set('teamAId', teamAId).set('teamBId', teamBId);
if (beforeMatchId != null) params = params.set('beforeMatchId', beforeMatchId);
return this.http.get<HeadToHead>(`${this.base}/reports/head-to-head`, { params });
}
predictMatch(matchId: number): Observable<MatchPrediction> {
return this.http.post<MatchPrediction>(`${this.base}/predictions/match/${matchId}`, {});
}
predictMatches(matchIds: number[]): Observable<MatchPrediction[]> {
return this.http.post<MatchPrediction[]>(`${this.base}/predictions/matches`, { matchIds });
}
getPlayerStats(playerId: number): Observable<PlayerStats> {
return this.http.get<PlayerStats>(`${this.base}/players/${playerId}/stats`);
}
getTeamStats(teamId: number): Observable<TeamStats> {
return this.http.get<TeamStats>(`${this.base}/teams/${teamId}/stats`);
}
lookup(
type: 'leagues' | 'seasons' | 'teams' | 'players' | 'matchdays',
params?: Record<string, string | number>,
): Observable<Lookup[]> {
let httpParams = new HttpParams();
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') httpParams = httpParams.set(k, v);
}
}
return this.http.get<Lookup[]>(`${this.base}/lookups/${type}`, { params: httpParams });
}
/** Builds a generic CRUD client for any REST resource (used by the metadata-driven CRUD page). */
crud<TRead = any, TWrite = any>(resource: string): CrudClient<TRead, TWrite> {
return new CrudClient<TRead, TWrite>(this.http, resource);
}
}

View File

@@ -0,0 +1,56 @@
import { Injectable, computed, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AuthResponse, LoginRequest } from '../models/models';
const STORAGE_KEY = 'bookie.auth';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly _auth = signal<AuthResponse | null>(this.restore());
readonly auth = this._auth.asReadonly();
readonly isLoggedIn = computed(() => {
const a = this._auth();
return !!a && new Date(a.expiresAt).getTime() > Date.now();
});
readonly username = computed(() => this._auth()?.username ?? '');
readonly role = computed(() => this._auth()?.role ?? '');
readonly isAdmin = computed(() => this.role() === 'admin');
constructor(private http: HttpClient) {}
login(request: LoginRequest): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${environment.apiBaseUrl}/auth/login`, request).pipe(
tap((res) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(res));
this._auth.set(res);
}),
);
}
logout(): void {
localStorage.removeItem(STORAGE_KEY);
this._auth.set(null);
}
get token(): string | null {
return this._auth()?.token ?? null;
}
private restore(): AuthResponse | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as AuthResponse;
if (new Date(parsed.expiresAt).getTime() <= Date.now()) {
localStorage.removeItem(STORAGE_KEY);
return null;
}
return parsed;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,50 @@
import jsPDF from 'jspdf';
import type { UserOptions } from 'jspdf-autotable';
const FONT_FILE = 'Roboto-Regular.ttf';
export const PDF_FONT_FAMILY = 'Roboto';
let fontBinary: string | null = null;
let fontLoadPromise: Promise<string> | null = null;
function arrayBufferToBinaryString(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return binary;
}
async function loadFontBinary(): Promise<string> {
if (fontBinary) return fontBinary;
if (!fontLoadPromise) {
fontLoadPromise = fetch(`/fonts/${FONT_FILE}`)
.then(async (res) => {
if (!res.ok) throw new Error(`Failed to load PDF font (${res.status})`);
return arrayBufferToBinaryString(await res.arrayBuffer());
})
.then((binary) => {
fontBinary = binary;
return binary;
});
}
return fontLoadPromise;
}
/** Registers Roboto (UTF-8 / Polish) on the given jsPDF instance. */
export async function registerPdfUnicodeFont(doc: jsPDF): Promise<void> {
const binary = await loadFontBinary();
const fonts = (doc as unknown as { getFontList?: () => Record<string, unknown> }).getFontList?.();
if (!fonts?.[PDF_FONT_FAMILY]) {
doc.addFileToVFS(FONT_FILE, binary);
doc.addFont(FONT_FILE, PDF_FONT_FAMILY, 'normal');
}
doc.setFont(PDF_FONT_FAMILY, 'normal');
}
export const pdfTableFontStyles: Partial<UserOptions> = {
styles: { font: PDF_FONT_FAMILY, fontStyle: 'normal' },
headStyles: { font: PDF_FONT_FAMILY, fontStyle: 'normal' },
};

View File

@@ -0,0 +1,368 @@
import { Chart, ChartConfiguration, registerables } from 'chart.js';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import {
MatchReport, MatchTeamActual, MatchTeamOpenAiPrediction, MatchTeamPrediction,
PreMatchReport, TeamReport,
} from '../models/models';
import { pdfTableFontStyles, PDF_FONT_FAMILY, registerPdfUnicodeFont } from './pdf-fonts';
Chart.register(...registerables);
export interface AnalysisExportResult {
included: number;
skipped: number;
}
interface MetricDef {
label: string;
llm: (p: MatchTeamPrediction) => number | null | undefined;
openai: (p: MatchTeamOpenAiPrediction) => number | null | undefined;
actual: (a: MatchTeamActual) => number | null | undefined;
/** When true, row is included with LLM + actual even if OpenAI has no value for this metric. */
openAiOptional?: boolean;
}
const METRICS: MetricDef[] = [
{ label: 'Goals', llm: (p) => p.predictedGoals, openai: (p) => p.predictedGoals, actual: (a) => a.goals },
{
label: 'Shots',
llm: (p) => p.predictedShotsTotal,
openai: () => null,
actual: (a) => a.shotsTotal,
openAiOptional: true,
},
{ label: 'On target', llm: (p) => p.predictedShotsOnTarget, openai: (p) => p.predictedShotsOnTarget, actual: (a) => a.shotsOnTarget },
{ label: 'Corners', llm: (p) => p.predictedCorners, openai: (p) => p.predictedCorners, actual: (a) => a.corners },
{ label: 'Fouls', llm: (p) => p.predictedFouls, openai: (p) => p.predictedFouls, actual: (a) => a.fouls },
{ label: 'Yellow cards', llm: (p) => p.predictedYellowCards, openai: (p) => p.predictedYellowCards, actual: (a) => a.yellowCards },
];
interface AnalyzableMatch {
match: MatchReport;
league: string;
country: string;
season: string;
}
interface MetricSample {
metric: string;
llm: number;
openai: number | null;
actual: number;
llmErr: number;
openaiErr: number | null;
matchLabel: string;
teamName: string;
}
function teamHasTriple(t: TeamReport): boolean {
return !!(t.prediction && t.openAiPrediction && t.actual?.hasData);
}
function matchQualifies(m: MatchReport): boolean {
return m.status === 'finished' && teamHasTriple(m.home) && teamHasTriple(m.away);
}
function n(v: number | null | undefined): number | null {
return v == null || Number.isNaN(Number(v)) ? null : Number(v);
}
function fmt(v: number | null | undefined): string {
if (v == null) return '—';
return Number.isInteger(v) ? `${v}` : v.toFixed(2);
}
function delta(actual: number, pred: number): string {
const d = Math.round((actual - pred) * 100) / 100;
const sign = d > 0 ? '+' : '';
return `${sign}${Number.isInteger(d) ? d : d.toFixed(2)}`;
}
function collectAnalyzable(report: PreMatchReport): { included: AnalyzableMatch[]; skipped: number } {
const included: AnalyzableMatch[] = [];
let skipped = 0;
for (const g of report.groups) {
for (const m of g.matches) {
if (m.status !== 'finished') continue;
if (matchQualifies(m)) {
included.push({ match: m, league: g.leagueName, country: g.country, season: g.seasonName });
} else {
skipped++;
}
}
}
return { included, skipped };
}
function collectSamples(matches: AnalyzableMatch[]): MetricSample[] {
const samples: MetricSample[] = [];
for (const { match: m } of matches) {
for (const [side, t] of [['Home', m.home], ['Away', m.away]] as const) {
const llm = t.prediction!;
const openai = t.openAiPrediction!;
const actual = t.actual!;
const label = `${m.homeTeamName} vs ${m.awayTeamName} (${side}: ${t.teamName})`;
for (const def of METRICS) {
const lv = n(def.llm(llm));
const ov = n(def.openai(openai));
const av = n(def.actual(actual));
if (lv == null || av == null) continue;
if (!def.openAiOptional && ov == null) continue;
samples.push({
metric: def.label,
llm: lv,
openai: ov,
actual: av,
llmErr: Math.abs(av - lv),
openaiErr: ov != null ? Math.abs(av - ov) : null,
matchLabel: label,
teamName: t.teamName,
});
}
}
}
return samples;
}
function mae(samples: MetricSample[], key: 'llmErr' | 'openaiErr'): number {
const usable = key === 'openaiErr'
? samples.filter((s) => s.openaiErr != null)
: samples;
if (!usable.length) return 0;
return usable.reduce((s, x) => s + (key === 'openaiErr' ? x.openaiErr! : x.llmErr), 0) / usable.length;
}
function maeByMetric(samples: MetricSample[], key: 'llmErr' | 'openaiErr'): Record<string, number> {
const acc: Record<string, { sum: number; n: number }> = {};
for (const s of samples) {
const err = key === 'openaiErr' ? s.openaiErr : s.llmErr;
if (err == null) continue;
if (!acc[s.metric]) acc[s.metric] = { sum: 0, n: 0 };
acc[s.metric].sum += err;
acc[s.metric].n++;
}
const out: Record<string, number> = {};
for (const [metric, v] of Object.entries(acc)) {
out[metric] = v.n ? v.sum / v.n : 0;
}
return out;
}
function buildNarrative(samples: MetricSample[], included: number, skipped: number): string[] {
const lines: string[] = [];
const llmMae = mae(samples, 'llmErr');
const openaiMae = mae(samples, 'openaiErr');
const llmByMetric = maeByMetric(samples, 'llmErr');
const openaiByMetric = maeByMetric(samples, 'openaiErr');
lines.push(
`This report compares stored LLM and OpenAI projections against actual match statistics for ${included} finished fixture(s) ` +
`where both teams had complete LLM, OpenAI, and actual data. ${skipped} finished match(es) were excluded due to missing data.`,
);
if (samples.length === 0) {
lines.push('No comparable metric samples were available for analysis.');
return lines;
}
const winner = llmMae < openaiMae - 0.05 ? 'LLM'
: openaiMae < llmMae - 0.05 ? 'OpenAI' : 'both models';
lines.push(
`Overall mean absolute error: LLM ${llmMae.toFixed(2)}, OpenAI ${openaiMae.toFixed(2)}. ` +
(winner === 'both models'
? 'Both models performed similarly on average.'
: `${winner} was closer to actual outcomes on average.`),
);
let bestMetric = '';
let bestLlm = Infinity;
for (const [metric, err] of Object.entries(llmByMetric)) {
if (err < bestLlm) { bestLlm = err; bestMetric = metric; }
}
if (bestMetric) {
lines.push(`Strongest area (lowest LLM error): ${bestMetric} (MAE ${bestLlm.toFixed(2)}).`);
}
let worstMetric = '';
let worstLlm = -1;
for (const [metric, err] of Object.entries(llmByMetric)) {
if (err > worstLlm) { worstLlm = err; worstMetric = metric; }
}
if (worstMetric) {
lines.push(`Weakest area (highest LLM error): ${worstMetric} (MAE ${worstLlm.toFixed(2)}).`);
}
const sorted = [...samples].sort((a, b) => b.llmErr - a.llmErr);
const worst = sorted.slice(0, 3);
if (worst.length) {
lines.push('Largest LLM misses: ' + worst.map((w) =>
`${w.metric} in ${w.matchLabel} (pred ${fmt(w.llm)}, actual ${fmt(w.actual)}, error ${w.llmErr.toFixed(2)})`,
).join('; ') + '.');
}
const good = samples.filter((s) => s.llmErr <= 0.5 && s.openaiErr != null && s.openaiErr <= 0.5);
if (good.length) {
lines.push(`${good.length} team-metric sample(s) were within 0.5 of actual for both models (excellent agreement).`);
}
const comparable = samples.filter((s) => s.openaiErr != null);
const openaiWins = comparable.filter((s) => s.openaiErr! < s.llmErr).length;
const llmWins = comparable.filter((s) => s.llmErr < s.openaiErr!).length;
lines.push(`Head-to-head per metric: OpenAI closer ${openaiWins} time(s), LLM closer ${llmWins} time(s).`);
for (const def of METRICS) {
const le = llmByMetric[def.label];
const oe = openaiByMetric[def.label];
if (le == null || oe == null) continue;
if (le < oe - 0.15) lines.push(`Good — LLM outperformed OpenAI on ${def.label.toLowerCase()} (MAE ${le.toFixed(2)} vs ${oe.toFixed(2)}).`);
else if (oe < le - 0.15) lines.push(`Concern — OpenAI was more accurate than LLM on ${def.label.toLowerCase()} (MAE ${oe.toFixed(2)} vs ${le.toFixed(2)}).`);
}
return lines;
}
async function chartToDataUrl(config: ChartConfiguration): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 320;
const chart = new Chart(canvas, config);
await new Promise((r) => setTimeout(r, 50));
const url = canvas.toDataURL('image/png', 1);
chart.destroy();
return url;
}
async function maeChart(samples: MetricSample[]): Promise<string | null> {
const llmBy = maeByMetric(samples, 'llmErr');
const openaiBy = maeByMetric(samples, 'openaiErr');
const labels = METRICS.map((m) => m.label).filter((l) => llmBy[l] != null);
if (!labels.length) return null;
return chartToDataUrl({
type: 'bar',
data: {
labels,
datasets: [
{ label: 'LLM MAE', data: labels.map((l) => llmBy[l]), backgroundColor: '#7e57c2', borderRadius: 4 },
{ label: 'OpenAI MAE', data: labels.map((l) => openaiBy[l] ?? null), backgroundColor: '#ef6c00', borderRadius: 4 },
],
},
options: {
responsive: false,
plugins: { legend: { position: 'bottom' }, title: { display: true, text: 'Mean absolute error by metric' } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'MAE (lower is better)' } } },
},
});
}
function teamTableBody(t: TeamReport): (string | number)[][] {
const llm = t.prediction!;
const openai = t.openAiPrediction!;
const actual = t.actual!;
return METRICS.map((def) => {
const lv = n(def.llm(llm));
const ov = n(def.openai(openai));
const av = n(def.actual(actual));
if (lv == null || av == null) return null;
if (!def.openAiOptional && ov == null) return null;
return [
def.label,
fmt(lv),
fmt(ov),
fmt(av),
delta(av, lv),
ov != null ? delta(av, ov) : '—',
ov == null ? '—' : (Math.abs(av - lv) <= Math.abs(av - ov) ? 'LLM' : 'OpenAI'),
];
}).filter((r): r is string[] => r != null);
}
export function countAnalyzableMatches(report: PreMatchReport): number {
return collectAnalyzable(report).included.length;
}
/** PDF analysis for finished matches with complete LLM + OpenAI + actual data for both teams. */
export async function exportPredictionAnalysisPdf(report: PreMatchReport): Promise<AnalysisExportResult> {
const { included, skipped } = collectAnalyzable(report);
const samples = collectSamples(included);
const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'a4' });
await registerPdfUnicodeFont(doc);
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
let y = 40;
doc.setFontSize(16);
doc.text(`Prediction Analysis — ${report.date}`, 40, y);
y += 22;
doc.setFontSize(10);
doc.setTextColor(100);
doc.text(`${included.length} match(es) included · ${skipped} finished match(es) skipped (incomplete data)`, 40, y);
doc.setTextColor(0);
y += 20;
doc.setFontSize(12);
doc.text('Executive summary', 40, y);
y += 14;
doc.setFontSize(9);
for (const line of buildNarrative(samples, included.length, skipped)) {
const wrapped = doc.splitTextToSize(line, pageW - 80);
if (y + wrapped.length * 11 > pageH - 60) { doc.addPage(); y = 40; }
doc.text(wrapped, 40, y);
y += wrapped.length * 11 + 6;
}
const chartUrl = await maeChart(samples);
if (chartUrl) {
if (y + 200 > pageH - 40) { doc.addPage(); y = 40; }
y += 8;
doc.setFontSize(12);
doc.text('Accuracy overview', 40, y);
y += 10;
doc.addImage(chartUrl, 'PNG', 40, y, pageW - 80, 180);
y += 190;
}
for (const item of included) {
const m = item.match;
if (y > pageH - 120) { doc.addPage(); y = 40; }
doc.setFontSize(11);
doc.setTextColor(21, 101, 192);
const kickoff = new Date(m.kickoffAt).toLocaleString();
const score = `${m.homeScoreFt}:${m.awayScoreFt}`;
doc.text(`${m.homeTeamName} ${score} ${m.awayTeamName}`, 40, y);
y += 12;
doc.setFontSize(8);
doc.setTextColor(100);
doc.text(`${item.league} (${item.country}) · ${item.season} · ${kickoff}`, 40, y);
doc.setTextColor(0);
y += 14;
for (const [side, t] of [['Home', m.home], ['Away', m.away]] as const) {
const body = teamTableBody(t);
if (!body.length) continue;
autoTable(doc, {
startY: y,
head: [[`${side}: ${t.teamName}`, 'LLM', 'OpenAI', 'Actual', 'LLM Δ', 'OpenAI Δ', 'Closer']],
body,
...pdfTableFontStyles,
styles: { ...pdfTableFontStyles.styles, fontSize: 8, cellPadding: 3 },
headStyles: {
...pdfTableFontStyles.headStyles,
fillColor: side === 'Home' ? [94, 53, 177] : [239, 108, 0],
},
margin: { left: 40, right: 40 },
});
doc.setFont(PDF_FONT_FAMILY, 'normal');
y = (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 10;
}
y += 8;
}
doc.save(`prediction-analysis-${report.date}.pdf`);
return { included: included.length, skipped };
}

View File

@@ -0,0 +1,203 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { FormResult, MatchPrediction, MetricEstimate, PreMatchReport, TeamReport } from '../models/models';
import { pdfTableFontStyles, registerPdfUnicodeFont } from './pdf-fonts';
function formZ(form: FormResult[]): string {
return form.length ? form.map((f) => f.result).join(' ') : '-';
}
function scorers(team: TeamReport): string {
if (!team.topScorers.length) return 'no data';
return team.topScorers.map((s) => `${s.playerName} (${s.goals}g/${s.assists}a)`).join('; ');
}
function csvCell(value: unknown): string {
const s = value == null ? '' : String(value);
return `"${s.replace(/"/g, '""')}"`;
}
/** Flattens the report into CSV rows (one row per team per match) and triggers a download. */
export function exportReportCsv(report: PreMatchReport): void {
const header = [
'Country', 'League', 'Season', 'Kickoff', 'Status', 'Match', 'Side', 'Team',
'MatchesPlayed', 'AvgGoalsFor', 'AvgGoalsAgainst', 'Possession', 'Shots', 'ShotsOnTarget',
'Corners', 'Fouls', 'Offsides', 'Yellow', 'Red', 'Form', 'TopScorers',
];
const rows: string[] = [header.map(csvCell).join(',')];
for (const g of report.groups) {
for (const m of g.matches) {
const matchLabel = `${m.homeTeamName} vs ${m.awayTeamName}`;
for (const [side, t] of [['Home', m.home], ['Away', m.away]] as const) {
const a = t.seasonAverages;
rows.push([
g.country, g.leagueName, g.seasonName, m.kickoffAt, m.status, matchLabel, side, t.teamName,
t.matchesPlayed, t.goalsForAgainst.avgGoalsFor ?? '', t.goalsForAgainst.avgGoalsAgainst ?? '',
a.possession ?? '', a.shotsTotal ?? '', a.shotsOnTarget ?? '', a.corners ?? '',
a.fouls ?? '', a.offsides ?? '', a.yellowCards ?? '', a.redCards ?? '',
formZ(t.form), scorers(t),
].map(csvCell).join(','));
}
}
}
downloadBlob(rows.join('\n'), `pre-match-report-${report.date}.csv`, 'text/csv;charset=utf-8;');
}
/** Renders the report into a multi-section PDF and triggers a download. */
export async function exportReportPdf(report: PreMatchReport): Promise<void> {
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' });
await registerPdfUnicodeFont(doc);
doc.setFontSize(16);
doc.text(`Pre-Match Report — ${report.date}`, 40, 40);
doc.setFontSize(10);
doc.text(`${report.matchCount} match(es)`, 40, 58);
let cursorY = 76;
for (const g of report.groups) {
doc.setFontSize(12);
doc.text(`${g.leagueName} (${g.country}) — ${g.seasonName}`, 40, cursorY);
cursorY += 8;
const body: (string | number)[][] = [];
for (const m of g.matches) {
const kickoff = new Date(m.kickoffAt).toLocaleString();
const score = m.hasScore ? `${m.homeScoreFt}:${m.awayScoreFt}` : '-';
body.push([`${kickoff} | ${m.homeTeamName} vs ${m.awayTeamName} (${m.status}) ${score}`, '', '', '']);
for (const [side, t] of [['H', m.home], ['A', m.away]] as const) {
const a = t.seasonAverages;
body.push([
` ${side}: ${t.teamName}`,
t.goalsForAgainst.hasData
? `GF ${t.goalsForAgainst.avgGoalsFor} / GA ${t.goalsForAgainst.avgGoalsAgainst}`
: 'no history',
a.hasData
? `Pos ${a.possession}%, Sh ${a.shotsTotal}(${a.shotsOnTarget}), Cor ${a.corners}, Fouls ${a.fouls}, Off ${a.offsides}, Y ${a.yellowCards}, R ${a.redCards}`
: 'no stats',
`Form ${formZ(t.form)} | ${scorers(t)}`,
]);
}
}
autoTable(doc, {
startY: cursorY,
head: [['Match / Team', 'Goals', 'Season averages', 'Form & scorers']],
body,
...pdfTableFontStyles,
styles: { ...pdfTableFontStyles.styles, fontSize: 7, cellPadding: 2, overflow: 'linebreak' },
headStyles: { ...pdfTableFontStyles.headStyles, fillColor: [21, 101, 192] },
columnStyles: { 0: { cellWidth: 200 }, 1: { cellWidth: 90 }, 2: { cellWidth: 320 }, 3: { cellWidth: 'auto' } },
margin: { left: 40, right: 40 },
});
cursorY = (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 24;
if (cursorY > 520) { doc.addPage(); cursorY = 40; }
}
doc.save(`pre-match-report-${report.date}.pdf`);
}
function metricCell(m: MetricEstimate): string {
const r = (n: number) => (Math.round(n * 10) / 10).toString();
return `${r(m.estimate)} (${r(m.low)}${r(m.high)})`;
}
/** Renders one or more match predictions into a PDF and triggers a download. */
export async function exportPredictionsPdf(predictions: MatchPrediction[]): Promise<void> {
const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'a4' });
await registerPdfUnicodeFont(doc);
const pageHeight = doc.internal.pageSize.getHeight();
doc.setFontSize(16);
doc.text('Match Statistics Projection', 40, 40);
doc.setFontSize(9);
doc.setTextColor(120);
doc.text(
`Generated by ${predictions[0]?.model ?? 'AI'} · statistical estimate for analytical purposes only — not a guaranteed outcome.`,
40, 56,
);
doc.setTextColor(0);
let cursorY = 78;
const metrics: { label: string; key: keyof MatchPrediction['home'] }[] = [
{ label: 'Goals', key: 'goals' },
{ label: 'Shots on target', key: 'shotsOnTarget' },
{ label: 'Corners', key: 'corners' },
{ label: 'Fouls', key: 'fouls' },
{ label: 'Yellow cards', key: 'yellowCards' },
{ label: 'Red cards', key: 'redCards' },
];
for (const p of predictions) {
if (cursorY > pageHeight - 140) { doc.addPage(); cursorY = 40; }
doc.setFontSize(12);
doc.text(`${p.homeTeam} vs ${p.awayTeam}`, 40, cursorY);
cursorY += 14;
doc.setFontSize(9);
doc.setTextColor(120);
const kickoff = new Date(p.kickoffAt).toLocaleString();
doc.text(`${p.league} · ${kickoff} · Confidence: ${p.confidence}`, 40, cursorY);
doc.setTextColor(0);
cursorY += 8;
const body = metrics.map((row) => [
row.label,
metricCell(p.home[row.key]),
metricCell(p.away[row.key]),
]);
autoTable(doc, {
startY: cursorY,
head: [['Expected', p.homeTeam, p.awayTeam]],
body,
...pdfTableFontStyles,
styles: { ...pdfTableFontStyles.styles, fontSize: 9, cellPadding: 4 },
headStyles: { ...pdfTableFontStyles.headStyles, fillColor: [106, 27, 154] },
columnStyles: { 0: { cellWidth: 140, fontStyle: 'normal' } },
margin: { left: 40, right: 40 },
});
cursorY = (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 14;
if (p.reasoning) {
const lines = doc.splitTextToSize(p.reasoning, doc.internal.pageSize.getWidth() - 80);
if (cursorY + lines.length * 12 > pageHeight - 40) { doc.addPage(); cursorY = 40; }
doc.setFontSize(9);
doc.text(lines, 40, cursorY);
cursorY += lines.length * 12 + 6;
}
if (p.flags?.length) {
doc.setFontSize(8);
doc.setTextColor(180, 120, 0);
for (const f of p.flags) {
if (cursorY > pageHeight - 40) { doc.addPage(); cursorY = 40; }
doc.text(`! ${f}`, 40, cursorY);
cursorY += 11;
}
doc.setTextColor(0);
}
cursorY += 18;
}
const stamp = new Date().toISOString().slice(0, 10);
const name = predictions.length === 1
? `prediction-${predictions[0].homeTeam}-vs-${predictions[0].awayTeam}-${stamp}.pdf`.replace(/\s+/g, '-')
: `predictions-${predictions.length}-matches-${stamp}.pdf`;
doc.save(name);
}
function downloadBlob(content: string, filename: string, type: string): void {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,48 @@
<div class="login-wrap">
<mat-card class="login-card">
@if (loading()) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<mat-card-header>
<mat-card-title class="login-title">
<mat-icon>sports_soccer</mat-icon>
Bookie
</mat-card-title>
<mat-card-subtitle>Football Stats Analyzer — sign in to continue</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-form-field class="full-width">
<mat-label>Username</mat-label>
<input matInput formControlName="username" autocomplete="username" />
<mat-icon matPrefix>person</mat-icon>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>Password</mat-label>
<input matInput [type]="hide() ? 'password' : 'text'" formControlName="password"
autocomplete="current-password" />
<mat-icon matPrefix>lock</mat-icon>
<button mat-icon-button matSuffix type="button" (click)="hide.set(!hide())"
[attr.aria-label]="'Toggle password visibility'">
<mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
</mat-form-field>
@if (error()) {
<p class="login-error">{{ error() }}</p>
}
<button mat-flat-button color="primary" class="full-width" type="submit"
[disabled]="form.invalid || loading()">
Sign in
</button>
</form>
<p class="login-hint muted">
Demo accounts — admin / admin123 &nbsp;&nbsp; user / user123
</p>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,34 @@
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1565c0, #0d47a1);
padding: 24px;
}
.login-card {
width: 100%;
max-width: 400px;
overflow: hidden;
}
.login-title {
display: flex;
align-items: center;
gap: 8px;
}
.login-error {
color: var(--bookie-loss);
margin: 0 0 12px;
font-size: 0.9rem;
}
.login-hint {
text-align: center;
margin-top: 16px;
font-size: 0.8rem;
}
form { margin-top: 8px; }

View File

@@ -0,0 +1,50 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule, MatCardModule, MatFormFieldModule, MatInputModule,
MatButtonModule, MatIconModule, MatProgressBarModule,
],
templateUrl: './login.component.html',
styleUrl: './login.component.scss',
})
export class LoginComponent {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly hide = signal(true);
readonly form = this.fb.nonNullable.group({
username: ['admin', Validators.required],
password: ['admin123', Validators.required],
});
submit() {
if (this.form.invalid) return;
this.loading.set(true);
this.error.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: () => {
this.loading.set(false);
this.router.navigate(['/dashboard']);
},
error: () => {
this.loading.set(false);
this.error.set('Invalid username or password.');
},
});
}
}

View File

@@ -0,0 +1,252 @@
export type FieldType = 'text' | 'number' | 'date' | 'datetime' | 'select' | 'textarea';
export type ColumnType = 'text' | 'date' | 'datetime' | 'boolean' | 'badge';
export type LookupType = 'leagues' | 'seasons' | 'teams' | 'players' | 'matchdays';
export type FilterType = 'text' | 'select' | 'date';
export type EntityLink = 'player' | 'team' | 'match';
export type RowActionType = 'match-stats' | 'head-to-head';
export interface SelectOption { value: string | number; label: string; }
/** Opens an entity stats/detail dialog when the cell value is clicked. */
export interface CellLink {
entity: EntityLink;
idKey: string;
}
/** A per-row icon-button action (available to all users), e.g. view match stats / head-to-head. */
export interface RowAction {
icon: string;
tooltip: string;
action: RowActionType;
color?: 'primary' | 'accent' | 'warn';
showWhen?: (row: any) => boolean;
}
/** A standalone filter control (not necessarily tied to a visible column). */
export interface FilterConfig {
/** Backend filter key. */
key: string;
label: string;
type: FilterType | 'lookup';
/** Options for a static select filter. */
options?: SelectOption[];
/** Data source for a dropdown populated from the API. */
lookup?: LookupType;
/**
* Other filter keys this lookup depends on. Their current values are passed as
* query params when loading options, and selecting a parent resets this filter.
*/
dependsOn?: string[];
}
export interface FieldConfig {
key: string;
label: string;
type: FieldType;
required?: boolean;
lookup?: LookupType;
options?: SelectOption[];
min?: number;
max?: number;
maxLength?: number;
hint?: string;
}
export interface ColumnConfig {
key: string;
label: string;
type?: ColumnType;
sortKey?: string;
filter?: FilterType;
/** Backend filter key; defaults to `key`. */
filterKey?: string;
filterOptions?: SelectOption[];
/** Renders the cell as a clickable link that opens the entity's stats dialog. */
link?: CellLink;
}
export interface CrudConfig {
resource: string;
title: string;
singular: string;
idKey: string;
columns: ColumnConfig[];
fields: FieldConfig[];
defaultSort?: string;
defaultSortDesc?: boolean;
rowActions?: RowAction[];
/** Explicit filter controls. When set, these replace column-derived filters. */
filters?: FilterConfig[];
}
const POSITIONS: SelectOption[] = [
{ value: 'GK', label: 'Goalkeeper' },
{ value: 'DF', label: 'Defender' },
{ value: 'MF', label: 'Midfielder' },
{ value: 'FW', label: 'Forward' },
];
const COMPETITION_TYPES: SelectOption[] = [
{ value: 'league', label: 'League' },
{ value: 'cup', label: 'Cup' },
];
const MATCH_STATUSES: SelectOption[] = [
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'live', label: 'Live' },
{ value: 'finished', label: 'Finished' },
{ value: 'cancelled', label: 'Cancelled' },
];
export const CRUD_CONFIGS: Record<string, CrudConfig> = {
leagues: {
resource: 'leagues',
title: 'Leagues',
singular: 'League',
idKey: 'leagueId',
defaultSort: 'name',
columns: [
{ key: 'name', label: 'Name', sortKey: 'name', filter: 'text' },
{ key: 'country', label: 'Country', sortKey: 'country', filter: 'text' },
{ key: 'tierLevel', label: 'Tier', sortKey: 'tierLevel' },
{ key: 'competitionType', label: 'Type', type: 'badge', sortKey: 'competitionType', filter: 'select', filterOptions: COMPETITION_TYPES },
{ key: 'seasonCount', label: 'Seasons' },
{ key: 'source', label: 'Source' },
],
fields: [
{ key: 'name', label: 'Name', type: 'text', required: true, maxLength: 100 },
{ key: 'country', label: 'Country', type: 'text', required: true, maxLength: 100 },
{ key: 'tierLevel', label: 'Tier level', type: 'number', required: true, min: 1, max: 20 },
{ key: 'competitionType', label: 'Competition type', type: 'select', required: true, options: COMPETITION_TYPES },
{ key: 'source', label: 'Source', type: 'text', maxLength: 200 },
],
},
seasons: {
resource: 'seasons',
title: 'Seasons',
singular: 'Season',
idKey: 'seasonId',
defaultSort: 'startDate',
defaultSortDesc: true,
columns: [
{ key: 'name', label: 'Name', sortKey: 'name', filter: 'text' },
{ key: 'leagueName', label: 'League', sortKey: 'league', filter: 'text', filterKey: 'leagueName' },
{ key: 'country', label: 'Country' },
{ key: 'startDate', label: 'Start', type: 'date', sortKey: 'startDate' },
{ key: 'endDate', label: 'End', type: 'date' },
],
fields: [
{ key: 'leagueId', label: 'League', type: 'select', required: true, lookup: 'leagues' },
{ key: 'name', label: 'Name (e.g. 2026/2027)', type: 'text', required: true, maxLength: 20 },
{ key: 'startDate', label: 'Start date', type: 'date', required: true },
{ key: 'endDate', label: 'End date', type: 'date' },
],
},
teams: {
resource: 'teams',
title: 'Teams',
singular: 'Team',
idKey: 'teamId',
defaultSort: 'name',
columns: [
{ key: 'name', label: 'Name', sortKey: 'name', filter: 'text', link: { entity: 'team', idKey: 'teamId' } },
{ key: 'shortName', label: 'Short' },
{ key: 'city', label: 'City', sortKey: 'city', filter: 'text' },
{ key: 'stadium', label: 'Stadium', filter: 'text' },
{ key: 'foundedDate', label: 'Founded', type: 'date' },
],
fields: [
{ key: 'name', label: 'Name', type: 'text', required: true, maxLength: 100 },
{ key: 'shortName', label: 'Short name', type: 'text', maxLength: 10 },
{ key: 'city', label: 'City', type: 'text', maxLength: 100 },
{ key: 'stadium', label: 'Stadium', type: 'text', maxLength: 150 },
{ key: 'foundedDate', label: 'Founded date', type: 'date' },
],
},
players: {
resource: 'players',
title: 'Players',
singular: 'Player',
idKey: 'playerId',
defaultSort: 'fullName',
columns: [
{ key: 'fullName', label: 'Full name', sortKey: 'fullName', filter: 'text', link: { entity: 'player', idKey: 'playerId' } },
{ key: 'primaryPosition', label: 'Position', type: 'badge', sortKey: 'position', filter: 'select', filterKey: 'position', filterOptions: POSITIONS },
{ key: 'nationality', label: 'Nationality', sortKey: 'nationality', filter: 'text' },
{ key: 'birthDate', label: 'Born', type: 'date', sortKey: 'birthDate' },
],
fields: [
{ key: 'fullName', label: 'Full name', type: 'text', required: true, maxLength: 150 },
{ key: 'primaryPosition', label: 'Primary position', type: 'select', options: POSITIONS },
{ key: 'nationality', label: 'Nationality', type: 'text', maxLength: 100 },
{ key: 'birthDate', label: 'Birth date', type: 'date' },
],
},
matches: {
resource: 'matches',
title: 'Matches',
singular: 'Match',
idKey: 'matchId',
defaultSort: 'kickoffAt',
defaultSortDesc: true,
columns: [
{ key: 'kickoffAt', label: 'Kickoff', type: 'datetime', sortKey: 'kickoffAt' },
{ key: 'homeTeamName', label: 'Home', sortKey: 'homeTeam', link: { entity: 'team', idKey: 'homeTeamId' } },
{ key: 'awayTeamName', label: 'Away', link: { entity: 'team', idKey: 'awayTeamId' } },
{ key: 'leagueName', label: 'League', sortKey: 'league' },
{ key: 'status', label: 'Status', type: 'badge', sortKey: 'status' },
],
filters: [
{ key: 'leagueId', label: 'League', type: 'lookup', lookup: 'leagues' },
{ key: 'seasonId', label: 'Season', type: 'lookup', lookup: 'seasons', dependsOn: ['leagueId'] },
{ key: 'teamId', label: 'Team', type: 'lookup', lookup: 'teams', dependsOn: ['leagueId', 'seasonId'] },
{ key: 'status', label: 'Status', type: 'select', options: MATCH_STATUSES },
{ key: 'dateFrom', label: 'From date', type: 'date' },
{ key: 'dateTo', label: 'To date', type: 'date' },
],
rowActions: [
{ icon: 'analytics', tooltip: 'Match statistics', action: 'match-stats', color: 'primary', showWhen: (r) => r.status === 'finished' },
{ icon: 'compare_arrows', tooltip: 'Head-to-head', action: 'head-to-head', color: 'accent' },
],
fields: [
{ key: 'matchdayId', label: 'Matchday', type: 'select', required: true, lookup: 'matchdays' },
{ key: 'homeTeamId', label: 'Home team', type: 'select', required: true, lookup: 'teams' },
{ key: 'awayTeamId', label: 'Away team', type: 'select', required: true, lookup: 'teams' },
{ key: 'kickoffAt', label: 'Kickoff', type: 'datetime', required: true },
{ key: 'status', label: 'Status', type: 'select', required: true, options: MATCH_STATUSES },
{ key: 'stadium', label: 'Stadium', type: 'text', maxLength: 150 },
{ key: 'referee', label: 'Referee', type: 'text', maxLength: 150 },
{ key: 'homeScoreHt', label: 'Home score (HT)', type: 'number', min: 0 },
{ key: 'awayScoreHt', label: 'Away score (HT)', type: 'number', min: 0 },
{ key: 'homeScoreFt', label: 'Home score (FT)', type: 'number', min: 0 },
{ key: 'awayScoreFt', label: 'Away score (FT)', type: 'number', min: 0 },
{ key: 'dataSource', label: 'Data source', type: 'text', maxLength: 200 },
],
},
contracts: {
resource: 'player-contracts',
title: 'Player Contracts',
singular: 'Contract',
idKey: 'contractId',
defaultSort: 'startDate',
defaultSortDesc: true,
columns: [
{ key: 'playerName', label: 'Player', sortKey: 'player', filter: 'text', filterKey: 'playerName', link: { entity: 'player', idKey: 'playerId' } },
{ key: 'teamName', label: 'Team', sortKey: 'team', filter: 'text', filterKey: 'teamName', link: { entity: 'team', idKey: 'teamId' } },
{ key: 'shirtNumber', label: 'Shirt #' },
{ key: 'startDate', label: 'Start', type: 'date', sortKey: 'startDate' },
{ key: 'endDate', label: 'End', type: 'date' },
{
key: 'isCurrent', label: 'Current', type: 'boolean', filter: 'select', filterKey: 'current',
filterOptions: [{ value: 'true', label: 'Current' }, { value: 'false', label: 'Ended' }],
},
],
fields: [
{ key: 'playerId', label: 'Player', type: 'select', required: true, lookup: 'players' },
{ key: 'teamId', label: 'Team', type: 'select', required: true, lookup: 'teams' },
{ key: 'shirtNumber', label: 'Shirt number', type: 'number', min: 1, max: 99 },
{ key: 'startDate', label: 'Start date', type: 'date', required: true },
{ key: 'endDate', label: 'End date (blank = current)', type: 'date' },
],
},
};

View File

@@ -0,0 +1,62 @@
<h2 mat-dialog-title>{{ isEdit ? 'Edit' : 'New' }} {{ data.config.singular }}</h2>
<mat-dialog-content>
<form [formGroup]="form" class="dialog-form">
@for (f of data.config.fields; track f.key) {
@switch (f.type) {
@case ('select') {
<mat-form-field class="full-width">
<mat-label>{{ f.label }}</mat-label>
<mat-select [formControlName]="f.key">
<mat-option [value]="null"></mat-option>
@for (opt of optionsFor(f.key); track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('date') {
<mat-form-field class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput [matDatepicker]="dp" [formControlName]="f.key" />
<mat-datepicker-toggle matSuffix [for]="dp"></mat-datepicker-toggle>
<mat-datepicker #dp></mat-datepicker>
</mat-form-field>
}
@case ('datetime') {
<mat-form-field class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput type="datetime-local" [formControlName]="f.key" />
</mat-form-field>
}
@case ('number') {
<mat-form-field class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput type="number" [formControlName]="f.key" [min]="f.min ?? null" [max]="f.max ?? null" />
@if (f.hint) { <mat-hint>{{ f.hint }}</mat-hint> }
</mat-form-field>
}
@case ('textarea') {
<mat-form-field class="full-width">
<mat-label>{{ f.label }}</mat-label>
<textarea matInput rows="3" [formControlName]="f.key"></textarea>
</mat-form-field>
}
@default {
<mat-form-field class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput [formControlName]="f.key" [maxlength]="f.maxLength ?? null" />
@if (f.hint) { <mat-hint>{{ f.hint }}</mat-hint> }
</mat-form-field>
}
}
}
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">Cancel</button>
<button mat-flat-button color="primary" (click)="save()" [disabled]="form.invalid">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,10 @@
.dialog-form {
display: flex;
flex-direction: column;
min-width: 360px;
padding-top: 8px;
}
@media (max-width: 480px) {
.dialog-form { min-width: unset; }
}

View File

@@ -0,0 +1,94 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/services/api.service';
import { CrudConfig, FieldConfig, SelectOption } from './crud-config';
interface DialogData { config: CrudConfig; entity: Record<string, any> | null; }
@Component({
selector: 'app-crud-form-dialog',
imports: [
ReactiveFormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatSelectModule,
MatDatepickerModule, MatButtonModule, MatIconModule,
],
templateUrl: './crud-form-dialog.component.html',
styleUrl: './crud-form-dialog.component.scss',
})
export class CrudFormDialogComponent {
private fb = inject(FormBuilder);
private api = inject(ApiService);
readonly data = inject<DialogData>(MAT_DIALOG_DATA);
private ref = inject(MatDialogRef<CrudFormDialogComponent>);
readonly isEdit = !!this.data.entity;
readonly form: FormGroup;
readonly options = signal<Record<string, SelectOption[]>>({});
constructor() {
const group: Record<string, any> = {};
for (const f of this.data.config.fields) {
const validators = [];
if (f.required) validators.push(Validators.required);
if (f.min != null) validators.push(Validators.min(f.min));
if (f.max != null) validators.push(Validators.max(f.max));
if (f.maxLength != null) validators.push(Validators.maxLength(f.maxLength));
group[f.key] = [this.initialValue(f), validators];
}
this.form = this.fb.group(group);
// Load lookup-backed select options.
for (const f of this.data.config.fields) {
if (f.options) {
this.options.update((o) => ({ ...o, [f.key]: f.options! }));
} else if (f.lookup) {
this.api.lookup(f.lookup).subscribe((items) => {
this.options.update((o) => ({ ...o, [f.key]: items.map((i) => ({ value: i.id, label: i.name })) }));
});
}
}
}
private initialValue(f: FieldConfig): any {
const raw = this.data.entity?.[f.key];
if (raw == null || raw === '') return f.type === 'select' ? null : null;
if (f.type === 'date') return new Date(raw);
if (f.type === 'datetime') return this.toLocalInput(new Date(raw));
return raw;
}
private toLocalInput(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
private toDateOnly(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
optionsFor(key: string): SelectOption[] {
return this.options()[key] ?? [];
}
save() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
const payload: Record<string, any> = {};
for (const f of this.data.config.fields) {
let v = this.form.value[f.key];
if (v === '' || v === undefined) v = null;
if (v != null && f.type === 'date' && v instanceof Date) v = this.toDateOnly(v);
if (v != null && f.type === 'number') v = Number(v);
payload[f.key] = v;
}
this.ref.close(payload);
}
cancel() { this.ref.close(null); }
}

View File

@@ -0,0 +1,120 @@
<div class="page">
<div class="page-header">
<h1>{{ config()?.title }}</h1>
</div>
<mat-card>
<div class="toolbar-row">
<mat-form-field appearance="outline" class="search-field" subscriptSizing="dynamic">
<mat-label>Search</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input matInput (input)="onSearch($any($event.target).value)" placeholder="Filter…" />
</mat-form-field>
<span class="spacer"></span>
<span class="muted">{{ total() }} record(s)</span>
</div>
@if (filterDefs().length) {
<div class="filter-row">
@for (def of filterDefs(); track def.key) {
@if (def.type === 'text') {
<mat-form-field appearance="outline" class="filter-field" subscriptSizing="dynamic">
<mat-label>{{ def.label }}</mat-label>
<input matInput [value]="filterValue(def)"
(input)="setFilter(def, $any($event.target).value)" placeholder="Contains…" />
</mat-form-field>
} @else if (def.type === 'date') {
<mat-form-field appearance="outline" class="filter-field" subscriptSizing="dynamic">
<mat-label>{{ def.label }}</mat-label>
<input matInput [matDatepicker]="picker" [value]="dateValue(def)"
(dateChange)="setDateFilter(def, $event.value)" />
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="filter-field" subscriptSizing="dynamic">
<mat-label>{{ def.label }}</mat-label>
<mat-select [value]="filterValue(def)" (selectionChange)="setFilter(def, $event.value)">
<mat-option [value]="''">All</mat-option>
@for (opt of optionsFor(def); track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
}
@if (hasActiveFilters()) {
<button mat-button (click)="clearFilters()">
<mat-icon>filter_alt_off</mat-icon> Clear
</button>
}
</div>
}
@if (loading()) { <mat-progress-bar mode="indeterminate"></mat-progress-bar> }
<div class="table-wrap">
<table mat-table [dataSource]="rows()" matSort (matSortChange)="onSort($event)">
@for (col of config()?.columns ?? []; track col.key) {
<ng-container [matColumnDef]="col.key">
<th mat-header-cell *matHeaderCellDef
[mat-sort-header]="col.sortKey ?? ''" [disabled]="!col.sortKey">
{{ col.label }}
</th>
<td mat-cell *matCellDef="let row">
@if (col.link && row[col.key] != null) {
<button type="button" class="link-cell" (click)="openLink(col, row)"
matTooltip="View statistics">{{ row[col.key] }}</button>
} @else {
@switch (col.type) {
@case ('date') { {{ row[col.key] ? (row[col.key] | date: 'mediumDate') : '—' }} }
@case ('datetime') { {{ row[col.key] ? (row[col.key] | date: 'medium') : '—' }} }
@case ('boolean') {
<mat-icon class="bool-icon" [class.yes]="row[col.key]">
{{ row[col.key] ? 'check_circle' : 'remove' }}
</mat-icon>
}
@case ('badge') {
@if (row[col.key]) { <span class="cell-badge">{{ row[col.key] }}</span> }
@else { <span class="muted"></span> }
}
@default { {{ row[col.key] ?? '—' }} }
}
}
</td>
</ng-container>
}
<ng-container matColumnDef="view">
<th mat-header-cell *matHeaderCellDef class="actions-cell">View</th>
<td mat-cell *matCellDef="let row" class="actions-cell">
@for (action of visibleActions(row); track action.action) {
<button mat-icon-button [color]="action.color ?? 'primary'"
(click)="runAction(action, row)" [matTooltip]="action.tooltip">
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns()"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns()"></tr>
</table>
@if (!loading() && rows().length === 0) {
<div class="empty-row muted">
<mat-icon>inbox</mat-icon> No records found.
</div>
}
</div>
<mat-paginator
[length]="total()"
[pageSize]="pageSize()"
[pageIndex]="page()"
[pageSizeOptions]="[5, 10, 20, 50]"
(page)="onPage($event)"
showFirstLastButtons>
</mat-paginator>
</mat-card>
</div>

View File

@@ -0,0 +1,50 @@
.search-field { width: 320px; max-width: 100%; }
.filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 4px 0 8px;
}
.filter-field { width: 180px; max-width: 100%; }
.link-cell {
background: none;
border: none;
padding: 0;
font: inherit;
color: #1565c0;
cursor: pointer;
text-align: left;
}
.link-cell:hover { text-decoration: underline; }
.table-wrap { overflow-x: auto; }
.cell-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
background: #e3f2fd;
color: #1565c0;
font-size: 0.78rem;
font-weight: 600;
text-transform: capitalize;
}
.bool-icon { color: rgba(0,0,0,0.3); }
.bool-icon.yes { color: var(--bookie-win); }
.empty-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
}
.actions-cell { text-align: right; white-space: nowrap; }
table { min-width: 600px; }

View File

@@ -0,0 +1,255 @@
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { ApiService } from '../../core/services/api.service';
import { CrudConfig, ColumnConfig, FilterConfig, RowAction, SelectOption } from './crud-config';
import { MatchDetailsDialogComponent } from '../pre-match/match-details-dialog.component';
import { HeadToHeadDialogComponent } from '../pre-match/head-to-head-dialog.component';
import { PlayerStatsDialogComponent } from '../stats/player-stats-dialog.component';
import { TeamStatsDialogComponent } from '../stats/team-stats-dialog.component';
@Component({
selector: 'app-crud-page',
imports: [
DatePipe, MatCardModule, MatTableModule, MatPaginatorModule, MatSortModule, MatFormFieldModule,
MatInputModule, MatSelectModule, MatDatepickerModule, MatButtonModule, MatIconModule,
MatProgressBarModule, MatChipsModule, MatTooltipModule,
],
templateUrl: './crud-page.component.html',
styleUrl: './crud-page.component.scss',
})
export class CrudPageComponent {
private route = inject(ActivatedRoute);
private api = inject(ApiService);
private dialog = inject(MatDialog);
readonly config = signal<CrudConfig | null>(null);
readonly rows = signal<any[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly page = signal(0);
readonly pageSize = signal(10);
readonly sortBy = signal<string | undefined>(undefined);
readonly sortDesc = signal(false);
private search = '';
readonly filters = signal<Record<string, string>>({});
readonly lookupOptions = signal<Record<string, SelectOption[]>>({});
readonly displayedColumns = signal<string[]>([]);
/** Unified filter definitions: explicit config.filters, else derived from filterable columns. */
readonly filterDefs = computed<FilterConfig[]>(() => {
const cfg = this.config();
if (!cfg) return [];
if (cfg.filters?.length) return cfg.filters;
return cfg.columns
.filter((c) => !!c.filter)
.map((c) => ({
key: c.filterKey ?? c.key,
label: c.label,
type: c.filter!,
options: c.filterOptions,
}));
});
readonly hasActiveFilters = computed(() => Object.keys(this.filters()).length > 0);
private searchInput$ = new Subject<string>();
private filterChange$ = new Subject<void>();
constructor() {
this.searchInput$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((term) => {
this.search = term;
this.page.set(0);
this.load();
});
this.filterChange$.pipe(debounceTime(300)).subscribe(() => {
this.page.set(0);
this.load();
});
this.route.data.subscribe((data) => {
const cfg = data['config'] as CrudConfig;
this.config.set(cfg);
this.search = '';
this.filters.set({});
this.page.set(0);
this.sortBy.set(cfg.defaultSort);
this.sortDesc.set(cfg.defaultSortDesc ?? false);
this.updateColumns();
this.loadLookups();
this.load();
});
}
private loadLookups() {
this.lookupOptions.set({});
this.filterDefs()
.filter((f) => f.type === 'lookup' && f.lookup)
.forEach((f) => this.loadLookup(f));
}
/** Loads options for one lookup filter, scoped by the current values of its dependencies. */
private loadLookup(def: FilterConfig) {
if (def.type !== 'lookup' || !def.lookup) return;
const params: Record<string, string> = {};
for (const dep of def.dependsOn ?? []) {
const v = this.filters()[dep];
if (v) params[dep] = v;
}
this.api.lookup(def.lookup, params).subscribe((items) => {
this.lookupOptions.update((cur) => ({
...cur,
[def.lookup!]: items.map((i) => ({ value: String(i.id), label: i.name })),
}));
});
}
optionsFor(def: FilterConfig): SelectOption[] {
if (def.type === 'lookup' && def.lookup) return this.lookupOptions()[def.lookup] ?? [];
return def.options ?? [];
}
private updateColumns() {
const cfg = this.config();
if (!cfg) return;
const cols = cfg.columns.map((c) => c.key);
if (cfg.rowActions?.length) cols.push('view');
this.displayedColumns.set(cols);
}
load() {
const cfg = this.config();
if (!cfg) return;
this.loading.set(true);
this.api.crud(cfg.resource).getPaged({
page: this.page() + 1,
pageSize: this.pageSize(),
search: this.search || undefined,
sortBy: this.sortBy(),
sortDesc: this.sortDesc(),
filters: this.filters(),
}).subscribe({
next: (res) => {
this.rows.set(res.items);
this.total.set(res.totalCount);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
onSearch(value: string) { this.searchInput$.next(value); }
setFilter(def: FilterConfig, value: string | number) {
const next = { ...this.filters() };
if (value == null || value === '') delete next[def.key];
else next[def.key] = String(value);
// Reset any filters that depend on this one (their previous choice may now be invalid).
const dependents = this.filterDefs().filter((f) => f.dependsOn?.includes(def.key));
for (const dep of dependents) delete next[dep.key];
this.filters.set(next);
// Reload dependent lookups with the new scope.
for (const dep of dependents) this.loadLookup(dep);
this.filterChange$.next();
}
filterValue(def: FilterConfig): string {
return this.filters()[def.key] ?? '';
}
/** For date filters: current value as a Date (for the datepicker) or null. */
dateValue(def: FilterConfig): Date | null {
const v = this.filters()[def.key];
return v ? new Date(v) : null;
}
/** Stores a picked date as YYYY-MM-DD (local), avoiding timezone shifts. */
setDateFilter(def: FilterConfig, value: Date | null) {
if (!value) { this.setFilter(def, ''); return; }
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
this.setFilter(def, `${y}-${m}-${d}`);
}
clearFilters() {
this.filters.set({});
this.page.set(0);
this.loadLookups();
this.load();
}
onPage(e: PageEvent) {
this.page.set(e.pageIndex);
this.pageSize.set(e.pageSize);
this.load();
}
onSort(e: Sort) {
this.sortBy.set(e.direction ? e.active : this.config()?.defaultSort);
this.sortDesc.set(e.direction === 'desc');
this.load();
}
// ---- Clickable cells + row actions ----
visibleActions(row: any): RowAction[] {
return (this.config()?.rowActions ?? []).filter((a) => !a.showWhen || a.showWhen(row));
}
openLink(col: ColumnConfig, row: any) {
if (!col.link) return;
const id = row[col.link.idKey];
if (id == null) return;
if (col.link.entity === 'player') {
this.dialog.open(PlayerStatsDialogComponent, { data: { playerId: id }, width: '600px', maxWidth: '95vw', autoFocus: false });
} else if (col.link.entity === 'team') {
this.dialog.open(TeamStatsDialogComponent, { data: { teamId: id }, width: '640px', maxWidth: '95vw', autoFocus: false });
} else if (col.link.entity === 'match') {
this.dialog.open(MatchDetailsDialogComponent, { data: { matchId: id }, width: '640px', maxWidth: '95vw', autoFocus: false });
}
}
runAction(action: RowAction, row: any) {
if (action.action === 'match-stats') {
this.dialog.open(MatchDetailsDialogComponent, {
data: { matchId: row.matchId }, width: '640px', maxWidth: '95vw', autoFocus: false,
});
} else if (action.action === 'head-to-head') {
this.dialog.open(HeadToHeadDialogComponent, {
data: {
teamAId: row.homeTeamId,
teamBId: row.awayTeamId,
teamAName: row.homeTeamName,
teamBName: row.awayTeamName,
beforeMatchId: row.matchId,
},
width: '560px', maxWidth: '95vw', autoFocus: false,
});
}
}
}

View File

@@ -0,0 +1,56 @@
<div class="page">
<div class="page-header">
<h1>Dashboard</h1>
</div>
@if (loading()) {
<div class="center-spinner"><mat-spinner diameter="48"></mat-spinner></div>
} @else {
<div class="widgets">
@for (w of widgets; track w.label) {
<mat-card class="widget" [routerLink]="w.link">
<div class="widget-icon" [style.background]="w.color">
<mat-icon>{{ w.icon }}</mat-icon>
</div>
<div class="widget-body">
<div class="widget-value">{{ w.value() }}</div>
<div class="widget-label muted">{{ w.label }}</div>
</div>
</mat-card>
}
</div>
<div class="dash-grid">
<mat-card class="chart-card">
<mat-card-header><mat-card-title>Matches by status</mat-card-title></mat-card-header>
<mat-card-content>
<div class="chart-wrap"><canvas #statusChart></canvas></div>
</mat-card-content>
</mat-card>
<mat-card class="next-card">
<mat-card-header>
<mat-card-title>Next fixtures</mat-card-title>
<a mat-button color="primary" routerLink="/pre-match">Pre-match reports</a>
</mat-card-header>
<mat-card-content>
@if (nextMatches().length) {
<mat-list>
@for (m of nextMatches(); track m.matchId) {
<mat-list-item>
<mat-icon matListItemIcon>sports_soccer</mat-icon>
<div matListItemTitle>{{ m.homeTeamName }} vs {{ m.awayTeamName }}</div>
<div matListItemLine class="muted">
{{ m.leagueName }} ({{ m.country }}) · {{ m.kickoffAt | date: 'EEE d MMM, HH:mm' }}
</div>
</mat-list-item>
}
</mat-list>
} @else {
<p class="muted">No upcoming scheduled fixtures.</p>
}
</mat-card-content>
</mat-card>
</div>
}
</div>

View File

@@ -0,0 +1,47 @@
.center-spinner { display: flex; justify-content: center; padding: 64px; }
.widgets {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.widget {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.widget:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.12); }
.widget-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.widget-icon mat-icon { font-size: 28px; width: 28px; height: 28px; }
.widget-value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
.widget-label { font-size: 0.85rem; margin-top: 4px; }
.dash-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 900px) { .dash-grid { grid-template-columns: 1fr; } }
.chart-wrap { height: 280px; position: relative; }
.next-card mat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -0,0 +1,88 @@
import { AfterViewInit, Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Chart, registerables } from 'chart.js';
import { ApiService } from '../../core/services/api.service';
import { DashboardSummary } from '../../core/models/models';
Chart.register(...registerables);
interface Widget { label: string; icon: string; value: () => number; color: string; link?: string; }
@Component({
selector: 'app-dashboard',
imports: [
DatePipe, RouterLink,
MatCardModule, MatIconModule, MatButtonModule, MatListModule, MatProgressSpinnerModule,
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
})
export class DashboardComponent implements AfterViewInit {
private api = inject(ApiService);
readonly loading = signal(true);
readonly summary = signal<DashboardSummary | null>(null);
private chartCanvas = viewChild<ElementRef<HTMLCanvasElement>>('statusChart');
private chart?: Chart;
readonly widgets: Widget[] = [
{ label: 'Leagues', icon: 'emoji_events', value: () => this.summary()?.leagueCount ?? 0, color: '#1565c0', link: '/leagues' },
{ label: 'Seasons', icon: 'calendar_month', value: () => this.summary()?.seasonCount ?? 0, color: '#00838f', link: '/seasons' },
{ label: 'Teams', icon: 'groups', value: () => this.summary()?.teamCount ?? 0, color: '#2e7d32', link: '/teams' },
{ label: 'Players', icon: 'person', value: () => this.summary()?.playerCount ?? 0, color: '#6a1b9a', link: '/players' },
{ label: 'Matches', icon: 'sports_soccer', value: () => this.summary()?.matchCount ?? 0, color: '#ef6c00', link: '/matches' },
{ label: 'Upcoming (7 days)', icon: 'upcoming', value: () => this.summary()?.upcomingMatchesThisWeek ?? 0, color: '#c62828', link: '/pre-match' },
];
readonly nextMatches = computed(() => this.summary()?.nextMatches ?? []);
constructor() {
effect(() => {
const s = this.summary();
if (s && this.chartCanvas()) this.renderChart(s);
});
}
ngAfterViewInit() {
this.api.getDashboard().subscribe({
next: (s) => { this.summary.set(s); this.loading.set(false); },
error: () => this.loading.set(false),
});
}
private renderChart(s: DashboardSummary) {
const canvas = this.chartCanvas()?.nativeElement;
if (!canvas) return;
this.chart?.destroy();
const colors: Record<string, string> = {
scheduled: '#1976d2', live: '#e53935', finished: '#2e7d32', cancelled: '#757575',
};
const data = s.statusBreakdown;
this.chart = new Chart(canvas, {
type: 'doughnut',
data: {
labels: data.map((d) => d.status),
datasets: [{
data: data.map((d) => d.count),
backgroundColor: data.map((d) => colors[d.status] ?? '#90a4ae'),
borderWidth: 2,
borderColor: '#fff',
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } },
cutout: '62%',
},
});
}
}

View File

@@ -0,0 +1,73 @@
<div class="dlg-header">
<div class="comp">
<mat-icon>history</mat-icon>
Head-to-head
<span class="muted">· {{ data.teamAName }} vs {{ data.teamBName }}</span>
</div>
<button mat-icon-button mat-dialog-close class="close-btn" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
@if (loading()) {
<div class="dlg-loading"><mat-spinner diameter="40"></mat-spinner></div>
} @else if (errored()) {
<p class="muted">Could not load head-to-head data.</p>
} @else {
@let d = h2h()!;
@if (!d.hasData) {
<div class="empty">
<mat-icon>search_off</mat-icon>
<p class="muted">No previous meetings between these teams.</p>
</div>
} @else {
<div class="summary">
<div class="counts">
<div class="count">
<b>{{ d.teamAWins }}</b>
<span class="muted">{{ d.teamAName }} wins</span>
</div>
<div class="count">
<b>{{ d.draws }}</b>
<span class="muted">Draws</span>
</div>
<div class="count">
<b>{{ d.teamBWins }}</b>
<span class="muted">{{ d.teamBName }} wins</span>
</div>
</div>
<div class="wdl-bar">
<div class="seg a" [style.width.%]="bar().a" [matTooltip]="d.teamAName + ' wins'"></div>
<div class="seg draw" [style.width.%]="bar().draw" matTooltip="Draws"></div>
<div class="seg b" [style.width.%]="bar().b" [matTooltip]="d.teamBName + ' wins'"></div>
</div>
<div class="totals muted">
{{ d.totalMeetings }} meeting(s) · goals {{ d.teamAGoals }}{{ d.teamBGoals }}
</div>
</div>
<h3 class="section-title">Previous meetings</h3>
<ul class="meeting-list">
@for (m of d.meetings; track m.matchId) {
<li>
<button type="button" class="meeting" (click)="openMatch(m.matchId)"
matTooltip="Click for full match stats">
<span class="date mono">{{ m.kickoffAt | date: 'd MMM y' }}</span>
<span class="teams">
<span class="t" [class.win]="(m.homeScoreFt ?? 0) > (m.awayScoreFt ?? 0)">{{ m.homeTeamName }}</span>
<span class="sc mono">{{ score(m) }}</span>
<span class="t" [class.win]="(m.awayScoreFt ?? 0) > (m.homeScoreFt ?? 0)">{{ m.awayTeamName }}</span>
</span>
<span class="comp-name muted">{{ m.leagueName }} · {{ m.seasonName }}</span>
</button>
</li>
}
</ul>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,67 @@
.dlg-loading { display: flex; justify-content: center; padding: 48px; min-width: 420px; }
.dlg-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 16px 0;
}
.comp { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.comp mat-icon { font-size: 20px; width: 20px; height: 20px; color: #1565c0; }
.close-btn { margin: -8px -8px 0 0; }
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px; }
.empty mat-icon { font-size: 40px; width: 40px; height: 40px; color: rgba(0,0,0,0.3); }
.summary { margin: 8px 0 4px; }
.counts { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center; }
.count { display: flex; flex-direction: column; }
.count b { font-size: 1.5rem; font-weight: 700; }
.count span { font-size: 0.75rem; }
.wdl-bar {
display: flex; height: 10px; border-radius: 5px; overflow: hidden;
background: #eceff3; margin: 10px 0 6px;
}
.wdl-bar .seg.a { background: #1565c0; }
.wdl-bar .seg.draw { background: #9e9e9e; }
.wdl-bar .seg.b { background: #ef6c00; }
.totals { text-align: center; font-size: 0.8rem; }
.section-title {
font-size: 0.95rem; font-weight: 600; margin: 18px 0 8px;
padding-bottom: 4px; border-bottom: 1px solid rgba(0,0,0,0.08);
}
.meeting-list { list-style: none; margin: 0; padding: 0; }
.meeting-list li { margin-bottom: 6px; }
.meeting {
display: grid;
grid-template-columns: 84px 1fr;
grid-template-areas: "date teams" "date comp";
gap: 2px 12px;
width: 100%;
text-align: left;
border: 1px solid rgba(0,0,0,0.08);
background: #f6f8fa;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.meeting:hover { background: #e3f2fd; border-color: #90caf9; }
.meeting .date { grid-area: date; align-self: center; color: rgba(0,0,0,0.6); font-size: 0.82rem; }
.meeting .teams {
grid-area: teams;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.meeting .teams .t:first-child { text-align: right; }
.meeting .teams .t:last-child { text-align: left; }
.meeting .teams .t.win { font-weight: 700; }
.meeting .teams .sc { font-weight: 700; }
.meeting .comp-name { grid-area: comp; font-size: 0.72rem; }

View File

@@ -0,0 +1,68 @@
import { Component, computed, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ApiService } from '../../core/services/api.service';
import { HeadToHead } from '../../core/models/models';
import { MatchDetailsDialogComponent } from './match-details-dialog.component';
interface DialogData {
teamAId: number;
teamBId: number;
teamAName: string;
teamBName: string;
beforeMatchId?: number;
}
@Component({
selector: 'app-head-to-head-dialog',
imports: [
DatePipe, MatDialogModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatTooltipModule,
],
templateUrl: './head-to-head-dialog.component.html',
styleUrl: './head-to-head-dialog.component.scss',
})
export class HeadToHeadDialogComponent {
private api = inject(ApiService);
private dialog = inject(MatDialog);
readonly data = inject<DialogData>(MAT_DIALOG_DATA);
readonly loading = signal(true);
readonly errored = signal(false);
readonly h2h = signal<HeadToHead | null>(null);
/** Percentage widths for the win/draw/win bar. */
readonly bar = computed(() => {
const d = this.h2h();
if (!d || d.totalMeetings === 0) return { a: 0, draw: 0, b: 0 };
const total = d.totalMeetings;
return {
a: Math.round((d.teamAWins / total) * 100),
draw: Math.round((d.draws / total) * 100),
b: Math.round((d.teamBWins / total) * 100),
};
});
constructor() {
this.api.getHeadToHead(this.data.teamAId, this.data.teamBId, this.data.beforeMatchId).subscribe({
next: (d) => { this.h2h.set(d); this.loading.set(false); },
error: () => { this.errored.set(true); this.loading.set(false); },
});
}
score(m: { homeScoreFt?: number | null; awayScoreFt?: number | null }): string {
return m.homeScoreFt == null || m.awayScoreFt == null ? '—' : `${m.homeScoreFt} : ${m.awayScoreFt}`;
}
openMatch(matchId: number) {
this.dialog.open(MatchDetailsDialogComponent, {
data: { matchId },
width: '640px',
maxWidth: '95vw',
autoFocus: false,
});
}
}

View File

@@ -0,0 +1,188 @@
@if (loading()) {
<div class="dlg-loading"><mat-spinner diameter="40"></mat-spinner></div>
} @else if (errored() || !details()) {
<h2 mat-dialog-title>Match details</h2>
<mat-dialog-content>
<p class="muted">Could not load match statistics.</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
} @else {
@let d = details()!;
<div class="dlg-header">
<div class="comp muted">
{{ d.leagueName }} ({{ d.country }}) · {{ d.seasonName }} · Round {{ d.matchdayNumber }}
</div>
<button mat-icon-button mat-dialog-close class="close-btn" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<div class="scoreline">
<div class="team home">{{ d.homeTeamName }}</div>
<div class="score">
@if (d.hasScore) {
<span class="ft mono">{{ d.homeScoreFt }} : {{ d.awayScoreFt }}</span>
@if (d.homeScoreHt != null) {
<span class="ht muted">HT {{ d.homeScoreHt }}:{{ d.awayScoreHt }}</span>
}
} @else {
<span class="status-chip">{{ d.status }}</span>
}
</div>
<div class="team away">{{ d.awayTeamName }}</div>
</div>
<div class="meta muted">
<span><mat-icon>event</mat-icon>{{ d.kickoffAt | date: 'EEE d MMM y, HH:mm' }}</span>
@if (d.stadium) { <span><mat-icon>stadium</mat-icon>{{ d.stadium }}</span> }
@if (d.referee) { <span><mat-icon>sports</mat-icon>{{ d.referee }}</span> }
</div>
<!-- Model predictions: LLM vs OpenAI vs actual (shown for any match with stored predictions) -->
@if (showComparison()) {
<h3 class="section-title">
Predictions{{ hasActual() ? ' vs actual' : '' }}
</h3>
<div class="pred-meta muted">
@if (anyPrediction(); as p) {
<span><span class="key llm"></span>LLM · trained {{ p.modelTrainedAt | date: 'mediumDate' }}@if (p.halfLifeDays != null) {, half-life {{ p.halfLifeDays }}d}</span>
}
@if (anyOpenAi(); as o) {
<span><span class="key openai"></span>OpenAI {{ o.model }}@if (o.confidence) { · {{ o.confidence }} confidence}</span>
}
@if (hasActual()) { <span><span class="key actual"></span>Actual</span> }
</div>
<div class="pred-charts">
<div class="pred-chart">
<div class="pred-team">{{ d.homeTeamName }}</div>
<div class="chart-box"><canvas #homePredChart></canvas></div>
</div>
<div class="pred-chart">
<div class="pred-team">{{ d.awayTeamName }}</div>
<div class="chart-box"><canvas #awayPredChart></canvas></div>
</div>
</div>
<table class="pred-table">
<thead>
<tr>
<th></th>
<th [attr.colspan]="modelCount()" class="grp">{{ d.homeTeamName }}</th>
<th [attr.colspan]="modelCount()" class="grp">{{ d.awayTeamName }}</th>
</tr>
<tr class="sub">
<th>Metric</th>
@if (hasLlm()) { <th>LLM</th> }
@if (hasOpenAi()) { <th>OpenAI</th> }
@if (hasActual()) { <th>Actual</th> }
@if (hasLlm() && hasActual()) { <th>Δ</th> }
@if (hasLlm()) { <th>LLM</th> }
@if (hasOpenAi()) { <th>OpenAI</th> }
@if (hasActual()) { <th>Actual</th> }
@if (hasLlm() && hasActual()) { <th>Δ</th> }
</tr>
</thead>
<tbody>
@for (r of predRows(); track r.label) {
<tr>
<td class="metric">{{ r.label }}</td>
@if (hasLlm()) { <td class="mono">{{ fmt(r.homeLlm) }}</td> }
@if (hasOpenAi()) { <td class="mono openai">{{ fmt(r.homeOpenAi) }}</td> }
@if (hasActual()) { <td class="mono actual">{{ fmt(r.homeActual) }}</td> }
@if (hasLlm() && hasActual()) {
<td [class]="'mono delta ' + deltaClass(r.homeActual, r.homeLlm)">{{ delta(r.homeActual, r.homeLlm) }}</td>
}
@if (hasLlm()) { <td class="mono">{{ fmt(r.awayLlm) }}</td> }
@if (hasOpenAi()) { <td class="mono openai">{{ fmt(r.awayOpenAi) }}</td> }
@if (hasActual()) { <td class="mono actual">{{ fmt(r.awayActual) }}</td> }
@if (hasLlm() && hasActual()) {
<td [class]="'mono delta ' + deltaClass(r.awayActual, r.awayLlm)">{{ delta(r.awayActual, r.awayLlm) }}</td>
}
</tr>
}
</tbody>
</table>
} @else if (d.hasScore) {
<p class="muted pred-missing">No stored model prediction for this match.</p>
}
<!-- Team stats comparison -->
<h3 class="section-title">Team statistics</h3>
@if (hasStats()) {
<div class="stats-compare">
@for (row of statRows(); track row.label) {
<div class="stat-line">
<span class="stat-val mono">{{ row.home }}</span>
<span class="stat-label">{{ row.label }}</span>
<span class="stat-val mono">{{ row.away }}</span>
</div>
<div class="stat-bar">
<div class="bar home" [style.width]="barWidth(row, 'home')"></div>
<div class="bar away" [style.width]="barWidth(row, 'away')"></div>
</div>
}
</div>
} @else {
<p class="muted">No per-team statistics recorded for this match.</p>
}
<!-- Goals -->
<h3 class="section-title">Goals</h3>
@if (d.goals.length) {
<ul class="event-list">
@for (g of d.goals; track $index) {
<li [class.away-ev]="!g.isHome">
<span class="ev-min mono">{{ minuteLabel(g.minute, g.addedTime) }}</span>
<mat-icon class="ev-icon">sports_soccer</mat-icon>
<span class="ev-text">
<b>{{ g.scorerName }}</b>
@if (g.assistName) { <span class="muted">(assist {{ g.assistName }})</span> }
@if (g.goalType !== 'open_play') { <span class="tag">{{ g.goalType }}</span> }
</span>
</li>
}
</ul>
} @else { <p class="muted">No goals recorded.</p> }
<!-- Cards -->
@if (d.cards.length) {
<h3 class="section-title">Cards</h3>
<ul class="event-list">
@for (c of d.cards; track $index) {
<li [class.away-ev]="!c.isHome">
<span class="ev-min mono">{{ c.minute }}'</span>
<span class="card-box" [class.red]="c.cardType === 'red'"></span>
<span class="ev-text"><b>{{ c.playerName }}</b>
@if (c.reason) { <span class="muted">— {{ c.reason }}</span> }
</span>
</li>
}
</ul>
}
<!-- Penalties -->
@if (d.penalties.length) {
<h3 class="section-title">Penalties</h3>
<ul class="event-list">
@for (p of d.penalties; track $index) {
<li [class.away-ev]="!p.isHome">
<span class="ev-min mono">{{ p.minute != null ? p.minute + "'" : '—' }}</span>
<mat-icon class="ev-icon">adjust</mat-icon>
<span class="ev-text">
<b>{{ p.playerName ?? 'Unknown' }}</b>
<span class="tag" [class.miss]="p.result !== 'scored'">{{ p.result }}</span>
</span>
</li>
}
</ul>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
}

View File

@@ -0,0 +1,123 @@
.dlg-loading { display: flex; justify-content: center; padding: 48px; min-width: 420px; }
.pred-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 0.8rem;
margin-bottom: 10px;
}
.pred-meta mat-icon { font-size: 16px; width: 16px; height: 16px; vertical-align: middle; margin-right: 2px; }
.pred-meta .key { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
.pred-meta .key.llm { background: #7e57c2; }
.pred-meta .key.openai { background: #ef6c00; }
.pred-meta .key.actual { background: #26a69a; }
.pred-charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.pred-chart .pred-team { font-weight: 600; text-align: center; font-size: 0.85rem; margin-bottom: 4px; }
.pred-chart .chart-box { position: relative; height: 180px; }
@media (max-width: 560px) {
.pred-charts { grid-template-columns: 1fr; }
}
.pred-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.pred-table th, .pred-table td { padding: 4px 6px; text-align: center; }
.pred-table thead .grp { border-bottom: 2px solid #eee; font-size: 0.78rem; }
.pred-table thead .sub th { color: rgba(0,0,0,0.55); font-weight: 500; }
.pred-table td.metric { text-align: left; font-weight: 500; }
.pred-table td.actual { color: #00695c; }
.pred-table td.openai { color: #e65100; }
.pred-table td.delta { font-size: 0.78rem; }
.pred-table td.delta-ok { color: #2e7d32; }
.pred-table td.delta-warn { color: #f57c00; }
.pred-table td.delta-bad { color: #c62828; }
.pred-missing { margin: 8px 0 4px; font-size: 0.85rem; font-style: italic; }
.pred-table tbody tr:nth-child(odd) { background: #fafafa; }
.dlg-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 16px 0;
}
.comp { font-size: 0.82rem; }
.close-btn { margin: -8px -8px 0 0; }
.scoreline {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
margin: 4px 0 12px;
}
.scoreline .team { font-size: 1.05rem; font-weight: 600; }
.scoreline .home { text-align: right; }
.scoreline .away { text-align: left; }
.score { display: flex; flex-direction: column; align-items: center; }
.ft { font-size: 1.5rem; font-weight: 700; }
.ht { font-size: 0.72rem; }
.status-chip {
padding: 3px 10px; border-radius: 12px; background: #1976d2; color: #fff;
font-size: 0.72rem; text-transform: uppercase; font-weight: 600;
}
.meta { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; font-size: 0.82rem; margin-bottom: 8px; }
.meta span { display: inline-flex; align-items: center; gap: 4px; }
.meta mat-icon { font-size: 17px; width: 17px; height: 17px; }
.section-title {
font-size: 0.95rem;
font-weight: 600;
margin: 18px 0 8px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.stats-compare { display: flex; flex-direction: column; gap: 6px; }
.stat-line {
display: grid;
grid-template-columns: 60px 1fr 60px;
align-items: center;
font-size: 0.85rem;
}
.stat-line .stat-label { text-align: center; color: rgba(0,0,0,0.6); }
.stat-line .stat-val { font-weight: 600; }
.stat-line .stat-val:first-child { text-align: right; }
.stat-line .stat-val:last-child { text-align: left; }
.stat-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: #eceff3; margin-bottom: 4px; }
.stat-bar .bar.home { background: #1565c0; }
.stat-bar .bar.away { background: #ef6c00; margin-left: auto; }
.event-list { list-style: none; margin: 0; padding: 0; }
.event-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
font-size: 0.88rem;
}
.event-list li:nth-child(odd) { background: #f6f8fa; }
.event-list li.away-ev { flex-direction: row-reverse; text-align: right; }
.event-list li.away-ev .ev-text { text-align: right; }
.ev-min { min-width: 42px; color: rgba(0,0,0,0.55); }
.ev-icon { font-size: 18px; width: 18px; height: 18px; color: #1565c0; }
.ev-text { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.tag {
background: #e3f2fd; color: #1565c0; border-radius: 8px; padding: 1px 6px;
font-size: 0.7rem; text-transform: capitalize;
}
.tag.miss { background: #ffebee; color: #c62828; }
.card-box { width: 11px; height: 15px; border-radius: 2px; background: #fbc02d; display: inline-block; }
.card-box.red { background: #d32f2f; }

View File

@@ -0,0 +1,226 @@
import { Component, ElementRef, OnDestroy, computed, effect, inject, signal, viewChild } from '@angular/core';
import { DatePipe } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Chart, registerables } from 'chart.js';
import { ApiService } from '../../core/services/api.service';
import { MatchDetails, MatchTeamPrediction, TeamMatchStats } from '../../core/models/models';
Chart.register(...registerables);
interface StatRow { label: string; home: string; away: string; homeNum: number; awayNum: number; }
interface PredRow {
label: string;
homeLlm: number | null; homeOpenAi: number | null; homeActual: number | null;
awayLlm: number | null; awayOpenAi: number | null; awayActual: number | null;
}
interface DialogData { matchId: number; }
@Component({
selector: 'app-match-details-dialog',
imports: [
DatePipe, MatDialogModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatTooltipModule,
],
templateUrl: './match-details-dialog.component.html',
styleUrl: './match-details-dialog.component.scss',
})
export class MatchDetailsDialogComponent implements OnDestroy {
private api = inject(ApiService);
readonly data = inject<DialogData>(MAT_DIALOG_DATA);
readonly loading = signal(true);
readonly errored = signal(false);
readonly details = signal<MatchDetails | null>(null);
readonly statRows = computed<StatRow[]>(() => {
const d = this.details();
if (!d) return [];
const h = d.homeStats;
const a = d.awayStats;
if (!h && !a) return [];
const num = (v: number | null | undefined) => (v == null ? 0 : Number(v));
const txt = (v: number | null | undefined, suffix = '') => (v == null ? '—' : `${v}${suffix}`);
return [
this.row('Possession', h?.possessionPct, a?.possessionPct, num, txt, '%'),
this.row('Shots', h?.shotsTotal, a?.shotsTotal, num, txt),
this.row('Shots on target', h?.shotsOnTarget, a?.shotsOnTarget, num, txt),
this.row('Corners', h?.corners, a?.corners, num, txt),
this.row('Fouls', h?.fouls, a?.fouls, num, txt),
this.row('Offsides', h?.offsides, a?.offsides, num, txt),
this.row('Yellow cards', h?.yellowCards, a?.yellowCards, num, txt),
this.row('Red cards', h?.redCards, a?.redCards, num, txt),
];
});
readonly hasStats = computed(() => !!(this.details()?.homeStats || this.details()?.awayStats));
private homeChartRef = viewChild<ElementRef<HTMLCanvasElement>>('homePredChart');
private awayChartRef = viewChild<ElementRef<HTMLCanvasElement>>('awayPredChart');
private homeChart?: Chart;
private awayChart?: Chart;
/** Predicted vs actual rows, one per comparable metric (LLM + OpenAI + actual). */
readonly predRows = computed<PredRow[]>(() => {
const d = this.details();
if (!d || (!d.hasPrediction && !d.hasOpenAiPrediction)) return [];
const hp = d.homePrediction;
const ap = d.awayPrediction;
const ho = d.homeOpenAiPrediction;
const ao = d.awayOpenAiPrediction;
const hs = d.homeStats;
const as = d.awayStats;
const n = (v: number | null | undefined) => (v == null ? null : Number(v));
const homeGoals = d.hasScore ? n(d.homeScoreFt) : null;
const awayGoals = d.hasScore ? n(d.awayScoreFt) : null;
return [
{ label: 'Goals', homeLlm: n(hp?.predictedGoals), homeOpenAi: n(ho?.predictedGoals), homeActual: homeGoals, awayLlm: n(ap?.predictedGoals), awayOpenAi: n(ao?.predictedGoals), awayActual: awayGoals },
{ label: 'Shots', homeLlm: n(hp?.predictedShotsTotal), homeOpenAi: null, homeActual: n(hs?.shotsTotal), awayLlm: n(ap?.predictedShotsTotal), awayOpenAi: null, awayActual: n(as?.shotsTotal) },
{ label: 'On target', homeLlm: n(hp?.predictedShotsOnTarget), homeOpenAi: n(ho?.predictedShotsOnTarget), homeActual: n(hs?.shotsOnTarget), awayLlm: n(ap?.predictedShotsOnTarget), awayOpenAi: n(ao?.predictedShotsOnTarget), awayActual: n(as?.shotsOnTarget) },
{ label: 'Corners', homeLlm: n(hp?.predictedCorners), homeOpenAi: n(ho?.predictedCorners), homeActual: n(hs?.corners), awayLlm: n(ap?.predictedCorners), awayOpenAi: n(ao?.predictedCorners), awayActual: n(as?.corners) },
{ label: 'Fouls', homeLlm: n(hp?.predictedFouls), homeOpenAi: n(ho?.predictedFouls), homeActual: n(hs?.fouls), awayLlm: n(ap?.predictedFouls), awayOpenAi: n(ao?.predictedFouls), awayActual: n(as?.fouls) },
{ label: 'Yellows', homeLlm: n(hp?.predictedYellowCards), homeOpenAi: n(ho?.predictedYellowCards), homeActual: n(hs?.yellowCards), awayLlm: n(ap?.predictedYellowCards), awayOpenAi: n(ao?.predictedYellowCards), awayActual: n(as?.yellowCards) },
];
});
/** True once at least one actual value exists (finished match with stats/score). */
readonly hasActual = computed(() =>
this.predRows().some((r) => r.homeActual != null || r.awayActual != null));
readonly hasLlm = computed(() => !!this.details()?.hasPrediction);
readonly hasOpenAi = computed(() => !!this.details()?.hasOpenAiPrediction);
readonly anyPrediction = computed(() =>
this.details()?.homePrediction ?? this.details()?.awayPrediction ?? null);
readonly anyOpenAi = computed(() =>
this.details()?.homeOpenAiPrediction ?? this.details()?.awayOpenAiPrediction ?? null);
/** Show the prediction comparison block (LLM and/or OpenAI vs actual). */
readonly showComparison = computed(() => {
const d = this.details();
if (!d) return false;
return d.hasPrediction || d.hasOpenAiPrediction;
});
constructor() {
this.api.getMatchDetails(this.data.matchId).subscribe({
next: (d) => { this.details.set(d); this.loading.set(false); },
error: () => { this.errored.set(true); this.loading.set(false); },
});
effect(() => {
const rows = this.predRows();
if (!rows.length || this.loading()) return;
// Canvas elements render one tick after details load — defer chart creation.
const timer = setTimeout(() => this.refreshCharts(rows), 0);
return () => clearTimeout(timer);
});
}
ngOnDestroy() {
this.homeChart?.destroy();
this.awayChart?.destroy();
}
private refreshCharts(rows: PredRow[]) {
this.homeChart = this.renderTeamChart(this.homeChart, this.homeChartRef()?.nativeElement, rows, 'home');
this.awayChart = this.renderTeamChart(this.awayChart, this.awayChartRef()?.nativeElement, rows, 'away');
}
private renderTeamChart(
existing: Chart | undefined,
canvas: HTMLCanvasElement | undefined,
rows: PredRow[],
side: 'home' | 'away',
): Chart | undefined {
if (!canvas) return existing;
existing?.destroy();
const datasets: any[] = [];
if (this.hasLlm()) {
datasets.push({
label: 'LLM',
data: rows.map((r) => (side === 'home' ? r.homeLlm : r.awayLlm)),
backgroundColor: '#7e57c2', borderRadius: 4,
});
}
if (this.hasOpenAi()) {
datasets.push({
label: 'OpenAI',
data: rows.map((r) => (side === 'home' ? r.homeOpenAi : r.awayOpenAi)),
backgroundColor: '#ef6c00', borderRadius: 4,
});
}
if (this.hasActual()) {
datasets.push({
label: 'Actual',
data: rows.map((r) => (side === 'home' ? r.homeActual : r.awayActual)),
backgroundColor: '#26a69a', borderRadius: 4,
});
}
return new Chart(canvas, {
type: 'bar',
data: { labels: rows.map((r) => r.label), datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 } } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
},
});
}
prediction(side: 'home' | 'away'): MatchTeamPrediction | null | undefined {
return side === 'home' ? this.details()?.homePrediction : this.details()?.awayPrediction;
}
fmt(v: number | null): string {
return v == null ? '—' : (Number.isInteger(v) ? `${v}` : v.toFixed(2));
}
/** Signed difference actual predicted (for finished matches). */
delta(actual: number | null, pred: number | null): string {
if (actual == null || pred == null) return '—';
const d = Math.round((actual - pred) * 100) / 100;
if (d === 0) return '0';
const sign = d > 0 ? '+' : '';
return `${sign}${Number.isInteger(d) ? d : d.toFixed(2)}`;
}
deltaClass(actual: number | null, pred: number | null): string {
if (actual == null || pred == null) return '';
const d = actual - pred;
if (Math.abs(d) < 0.01) return 'delta-ok';
return Math.abs(d) <= 1 ? 'delta-warn' : 'delta-bad';
}
/** Number of model columns shown per team in the comparison table. */
modelCount(): number {
let n = (this.hasLlm() ? 1 : 0) + (this.hasOpenAi() ? 1 : 0) + (this.hasActual() ? 1 : 0);
if (this.hasLlm() && this.hasActual()) n += 1;
return n;
}
private row(
label: string,
h: number | null | undefined,
a: number | null | undefined,
num: (v: number | null | undefined) => number,
txt: (v: number | null | undefined, s?: string) => string,
suffix = '',
): StatRow {
return { label, home: txt(h, suffix), away: txt(a, suffix), homeNum: num(h), awayNum: num(a) };
}
barWidth(row: StatRow, side: 'home' | 'away'): string {
const total = row.homeNum + row.awayNum;
if (total <= 0) return '0%';
const val = side === 'home' ? row.homeNum : row.awayNum;
return `${Math.round((val / total) * 100)}%`;
}
minuteLabel(minute: number, added: number): string {
return added > 0 ? `${minute}+${added}'` : `${minute}'`;
}
}

View File

@@ -0,0 +1,421 @@
<div class="page">
<div class="page-header">
<h1>Pre-Match Reports</h1>
<div class="toolbar-row" style="margin: 0;">
<mat-form-field appearance="outline" class="date-field">
<mat-label>Report date</mat-label>
<input matInput [matDatepicker]="picker" [formControl]="dateControl" (dateChange)="load()" />
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="seasons-field">
<mat-label>History depth</mat-label>
<mat-select [formControl]="seasonsBackControl" (selectionChange)="load()">
@for (opt of seasonsBackOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-flat-button color="primary" (click)="load()">
<mat-icon>refresh</mat-icon> Refresh
</button>
<button mat-stroked-button (click)="exportCsv()" [disabled]="!hasMatches()">
<mat-icon>table_view</mat-icon> CSV
</button>
<button mat-stroked-button (click)="exportPdf()" [disabled]="!hasMatches()">
<mat-icon>picture_as_pdf</mat-icon> PDF
</button>
<button mat-flat-button class="analyze-btn" (click)="analyze()"
[disabled]="analyzableCount() === 0 || analyzing()"
[matTooltip]="analyzableCount() === 0
? 'Requires finished matches with LLM, OpenAI and actual stats for both teams'
: 'Generate analysis PDF for ' + analyzableCount() + ' match(es)'">
@if (analyzing()) {
<mat-icon class="spin">hourglass_top</mat-icon>
} @else {
<mat-icon>analytics</mat-icon>
}
Analyze
@if (analyzableCount() > 0) {
<span class="analyze-count">({{ analyzableCount() }})</span>
}
</button>
<button mat-flat-button class="predict-selected-btn" (click)="predictSelected()"
[disabled]="selectedCount() === 0"
[matBadge]="selectedCount()" [matBadgeHidden]="selectedCount() === 0" matBadgeColor="accent">
<mat-icon>insights</mat-icon> Predict selected
</button>
</div>
</div>
<!-- Loading skeleton -->
@if (loading()) {
<div class="skeleton-list">
@for (s of skeletons; track s) {
<mat-card class="skeleton-card">
<div class="sk sk-title"></div>
<div class="sk sk-line"></div>
<div class="sk sk-line short"></div>
<div class="sk-panels">
<div class="sk sk-panel"></div>
<div class="sk sk-panel"></div>
</div>
</mat-card>
}
</div>
} @else if (errored()) {
<mat-card class="state-card">
<mat-icon class="state-icon">error_outline</mat-icon>
<h2>Could not load the report</h2>
<p class="muted">The API request failed. Check that the backend is running and try again.</p>
<button mat-flat-button color="primary" (click)="load()">Retry</button>
</mat-card>
} @else if (!hasMatches()) {
<mat-card class="state-card">
<mat-icon class="state-icon">event_busy</mat-icon>
<h2>No matches on this date</h2>
<p class="muted">There are no fixtures scheduled for {{ dateControl.value | date: 'fullDate' }}.</p>
</mat-card>
} @else {
<div class="result-count">
<span class="muted">{{ report()!.matchCount }} match(es) on {{ report()!.date }}</span>
<span class="select-tools">
@if (selectedCount() > 0) {
<span class="muted small">{{ selectedCount() }} selected</span>
<button mat-button (click)="clearSelection()">Clear</button>
}
<button mat-button (click)="selectAll()">Select all</button>
</span>
</div>
@for (group of report()!.groups; track group.leagueId + '-' + group.seasonId) {
<mat-expansion-panel class="league-panel" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon class="flag-icon">emoji_events</mat-icon>
{{ group.leagueName }}
<span class="muted country">&nbsp;— {{ group.country }}</span>
</mat-panel-title>
<mat-panel-description>
Season {{ group.seasonName }} · {{ group.matches.length }} match(es)
</mat-panel-description>
</mat-expansion-panel-header>
@if (group.modelParams) {
<div class="league-model-bar">
<mat-icon>model_training</mat-icon>
<span class="model-label">Dixon-Coles model</span>
<span class="model-chip">HA {{ group.modelParams.homeAdvantage }}</span>
<span class="model-chip">ρ {{ group.modelParams.rho }}</span>
@if (group.modelParams.avgHomeGoals != null) {
<span class="model-chip">avg H {{ group.modelParams.avgHomeGoals }}</span>
}
@if (group.modelParams.avgAwayGoals != null) {
<span class="model-chip">avg A {{ group.modelParams.avgAwayGoals }}</span>
}
<span class="muted small">
{{ group.modelParams.matchesUsed }} matches
@if (group.modelParams.halfLifeDays) { · half-life {{ group.modelParams.halfLifeDays }}d }
· trained {{ group.modelParams.trainedAt | date: 'mediumDate' }}
</span>
</div>
}
@for (m of group.matches; track m.matchId) {
<mat-card class="match-card" [class.selected]="isSelected(m.matchId)">
<div class="match-head">
<mat-checkbox class="select-box" [checked]="isSelected(m.matchId)"
(change)="toggleSelected(m.matchId)"
matTooltip="Select for batch prediction"></mat-checkbox>
<div class="kickoff mono">{{ m.kickoffAt | date: 'HH:mm' }}</div>
<div class="teams">
<span class="team-name">{{ m.homeTeamName }}</span>
@if (m.hasScore) {
<span class="score mono">{{ m.homeScoreFt }} : {{ m.awayScoreFt }}</span>
} @else {
<span class="vs">vs</span>
}
<span class="team-name">{{ m.awayTeamName }}</span>
</div>
<span class="status-badge" [class]="statusClass(m.status)">
{{ statusLabels[m.status] ?? m.status }}
</span>
</div>
@if (m.stadium || m.referee) {
<div class="match-meta muted">
@if (m.stadium) { <span><mat-icon>stadium</mat-icon>{{ m.stadium }}</span> }
@if (m.referee) { <span><mat-icon>sports</mat-icon>{{ m.referee }}</span> }
@if (m.hasScore) { <span><mat-icon>schedule</mat-icon>HT {{ m.homeScoreHt }}:{{ m.awayScoreHt }}</span> }
</div>
}
<div class="match-actions">
<span class="history-note muted">
<mat-icon>history_toggle_off</mat-icon>{{ seasonsLabel(m) }}
</span>
<span class="action-buttons">
<button mat-stroked-button color="primary" (click)="openHeadToHead(m)">
<mat-icon>compare_arrows</mat-icon> Head-to-head
</button>
<button mat-flat-button class="predict-btn" (click)="openPrediction(m)">
<mat-icon>insights</mat-icon> Predict
</button>
</span>
</div>
@if (m.odds) {
<div class="odds-block">
<span class="muted small">
<mat-icon>casino</mat-icon>
Market 1X2 (avg {{ m.odds.bookmakerCount }} bookmaker{{ m.odds.bookmakerCount === 1 ? '' : 's' }})
</span>
<div class="odds-grid">
<div class="odds-cell home-odds">
<span class="muted">Home</span>
<b class="mono">{{ m.odds.avgHomeOdds | number: '1.2-2' }}</b>
<span class="implied">{{ (m.odds.homeImplied * 100) | number: '1.1-1' }}%</span>
</div>
<div class="odds-cell draw-odds">
<span class="muted">Draw</span>
<b class="mono">{{ m.odds.avgDrawOdds | number: '1.2-2' }}</b>
<span class="implied">{{ (m.odds.drawImplied * 100) | number: '1.1-1' }}%</span>
</div>
<div class="odds-cell away-odds">
<span class="muted">Away</span>
<b class="mono">{{ m.odds.avgAwayOdds | number: '1.2-2' }}</b>
<span class="implied">{{ (m.odds.awayImplied * 100) | number: '1.1-1' }}%</span>
</div>
</div>
@if (m.odds.avgTotalLine != null || m.odds.avgOverOdds != null || m.odds.avgUnderOdds != null) {
<div class="totals-row">
<span class="muted small">Totals O/U</span>
@if (m.odds.avgTotalLine != null) {
<span class="totals-chip">Line <b class="mono">{{ m.odds.avgTotalLine }}</b></span>
}
@if (m.odds.avgOverOdds != null) {
<span class="totals-chip">
Over <b class="mono">{{ m.odds.avgOverOdds | number: '1.2-2' }}</b>
@if (m.odds.overImplied != null) {
<span class="implied">{{ (m.odds.overImplied * 100) | number: '1.1-1' }}%</span>
}
</span>
}
@if (m.odds.avgUnderOdds != null) {
<span class="totals-chip">
Under <b class="mono">{{ m.odds.avgUnderOdds | number: '1.2-2' }}</b>
@if (m.odds.underImplied != null) {
<span class="implied">{{ (m.odds.underImplied * 100) | number: '1.1-1' }}%</span>
}
</span>
}
</div>
}
</div>
}
@if (m.extraOdds?.length) {
<div class="extra-odds-block">
<span class="muted small"><mat-icon>stacked_line_chart</mat-icon> Extra markets</span>
<table class="extra-odds-table">
<thead>
<tr>
<th>Market</th>
<th>Bookmaker</th>
<th class="num-col">Line</th>
<th class="num-col">Over</th>
<th class="num-col">Under</th>
</tr>
</thead>
<tbody>
@for (eo of m.extraOdds; track eo.market + eo.bookmaker) {
<tr>
<td>{{ extraMarketLabel(eo.market) }}</td>
<td>{{ eo.bookmaker }}</td>
<td class="mono num-col">{{ eo.line }}</td>
<td class="mono num-col">{{ eo.overOdds | number: '1.2-2' }}</td>
<td class="mono num-col">{{ eo.underOdds | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="team-panels">
@for (t of [m.home, m.away]; track t.teamId) {
<div class="team-panel">
<div class="team-panel-head">
<span class="team-panel-name">{{ t.teamName }}</span>
<span class="muted">based on {{ t.matchesPlayed }} earlier match(es)</span>
</div>
@if (t.strength) {
<div class="strength-row">
<mat-icon>fitness_center</mat-icon>
<span>
Atk <b class="mono">{{ t.strength.attackFactor }}</b>
· Def <b class="mono">{{ t.strength.defenseFactor }}</b>
<span class="muted small">({{ t.strength.matchesUsed }} matches)</span>
</span>
</div>
}
@if (t.oddsApiName) {
<div class="alias-row muted small">
<mat-icon>link</mat-icon> Odds API: {{ t.oddsApiName }}
</div>
}
@if (!t.hasHistory) {
<div class="no-history">
<mat-icon>info</mat-icon> No historical data in the selected window.
</div>
} @else {
<div class="stat-row goals">
<mat-icon>sports_soccer</mat-icon>
<span>{{ goalsSummary(t) }}</span>
</div>
@if (t.seasonAverages.hasData) {
<div class="averages-grid">
<div class="avg"><span class="muted">Possession</span><b>{{ t.seasonAverages.possession }}%</b></div>
<div class="avg"><span class="muted">Shots</span><b>{{ t.seasonAverages.shotsTotal }}</b></div>
<div class="avg"><span class="muted">On target</span><b>{{ t.seasonAverages.shotsOnTarget }}</b></div>
<div class="avg"><span class="muted">Corners</span><b>{{ t.seasonAverages.corners }}</b></div>
<div class="avg"><span class="muted">Fouls</span><b>{{ t.seasonAverages.fouls }}</b></div>
<div class="avg"><span class="muted">Offsides</span><b>{{ t.seasonAverages.offsides }}</b></div>
<div class="avg"><span class="muted">Yellow</span><b>{{ t.seasonAverages.yellowCards }}</b></div>
<div class="avg"><span class="muted">Red</span><b>{{ t.seasonAverages.redCards }}</b></div>
</div>
} @else {
<div class="muted small">Detailed match stats: no data.</div>
}
<div class="form-row">
<span class="muted">Form:</span>
@if (t.form.length) {
@for (r of t.form; track $index) {
<button type="button" class="form-chip" [class]="chipClass(r.result)"
[matTooltip]="formTooltip(r)" matTooltipPosition="above"
(click)="openMatch(r.matchId)">{{ r.result }}</button>
}
} @else {
<span class="muted small">no data</span>
}
</div>
<div class="scorers">
<span class="muted small">Top scorers (goals + assists so far)</span>
@if (t.topScorers.length) {
<table class="scorer-table">
<tbody>
@for (s of t.topScorers; track s.playerName) {
<tr>
<td class="scorer-name">{{ s.playerName }}</td>
<td class="mono">{{ s.goals }}G</td>
<td class="mono">{{ s.assists }}A</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="muted small">no data</div>
}
</div>
}
@if (t.prediction || t.openAiPrediction || t.actual?.hasData) {
<div class="pred-block">
<span class="muted small">
@if (t.actual?.hasData && (t.prediction || t.openAiPrediction)) {
Predictions vs actual
} @else if (t.actual?.hasData) {
Match statistics
} @else {
Predictions (per this match)
}
</span>
<table class="pred-table">
<thead>
<tr>
<th>Metric</th>
@if (t.prediction) { <th class="num-col llm-col">LLM</th> }
@if (t.openAiPrediction) { <th class="num-col openai-col">OpenAI</th> }
@if (t.actual?.hasData) { <th class="num-col actual-col">Actual</th> }
</tr>
</thead>
<tbody>
<tr>
<td>Goals</td>
@if (t.prediction) { <td class="mono num-col">{{ t.prediction.predictedGoals ?? '—' }}</td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val">{{ t.openAiPrediction.predictedGoals ?? '—' }}</td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.goals ?? '—' }}</td> }
</tr>
@if (t.prediction || t.actual?.shotsTotal != null) {
<tr>
<td>Shots</td>
@if (t.prediction) { <td class="mono num-col">{{ t.prediction.predictedShotsTotal ?? '—' }}</td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val"></td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.shotsTotal ?? '—' }}</td> }
</tr>
}
<tr>
<td>On target</td>
@if (t.prediction) { <td class="mono num-col">{{ t.prediction.predictedShotsOnTarget ?? '—' }}</td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val">{{ t.openAiPrediction.predictedShotsOnTarget ?? '—' }}</td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.shotsOnTarget ?? '—' }}</td> }
</tr>
<tr>
<td>Corners</td>
@if (t.prediction) { <td class="mono num-col">{{ t.prediction.predictedCorners ?? '—' }}</td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val">{{ t.openAiPrediction.predictedCorners ?? '—' }}</td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.corners ?? '—' }}</td> }
</tr>
<tr>
<td>Fouls</td>
@if (t.prediction) { <td class="mono num-col">{{ t.prediction.predictedFouls ?? '—' }}</td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val">{{ t.openAiPrediction.predictedFouls ?? '—' }}</td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.fouls ?? '—' }}</td> }
</tr>
<tr>
<td>Yellow cards</td>
@if (t.prediction) { <td class="mono num-col">{{ t.prediction.predictedYellowCards ?? '—' }}</td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val">{{ t.openAiPrediction.predictedYellowCards ?? '—' }}</td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.yellowCards }}</td> }
</tr>
@if (t.openAiPrediction || t.actual?.hasData) {
<tr>
<td>Red cards</td>
@if (t.prediction) { <td class="mono num-col"></td> }
@if (t.openAiPrediction) { <td class="mono num-col openai-val">{{ t.openAiPrediction.predictedRedCards ?? '—' }}</td> }
@if (t.actual?.hasData) { <td class="mono num-col actual-val">{{ t.actual!.redCards }}</td> }
</tr>
}
</tbody>
</table>
@if (t.openAiPrediction) {
<div class="openai-meta muted small">
@if (t.openAiPrediction.model) { <span>{{ t.openAiPrediction.model }}</span> }
@if (t.openAiPrediction.confidence) { <span>· {{ t.openAiPrediction.confidence }} confidence</span> }
@if (t.openAiPrediction.predictedAt) { <span>· {{ t.openAiPrediction.predictedAt | date: 'medium' }}</span> }
</div>
}
@if (t.prediction) {
<div class="llm-meta muted small">
<span>LLM model</span>
@if (t.prediction.halfLifeDays) { <span>· half-life {{ t.prediction.halfLifeDays }}d</span> }
<span>· trained {{ t.prediction.modelTrainedAt | date: 'mediumDate' }}</span>
<span>· saved {{ t.prediction.predictedAt | date: 'short' }}</span>
</div>
}
</div>
}
</div>
}
</div>
</mat-card>
}
</mat-expansion-panel>
}
}
</div>

View File

@@ -0,0 +1,382 @@
.date-field { width: 200px; }
.seasons-field { width: 220px; }
.result-count {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 16px;
flex-wrap: wrap;
}
.select-tools { display: inline-flex; align-items: center; gap: 6px; }
.predict-selected-btn {
background: linear-gradient(135deg, #6a1b9a, #8e24aa);
color: #fff;
}
.predict-selected-btn[disabled] {
background: rgba(0, 0, 0, 0.12);
color: rgba(0, 0, 0, 0.38);
}
.analyze-btn {
background: linear-gradient(135deg, #1565c0, #1976d2);
color: #fff;
}
.analyze-btn[disabled] {
background: rgba(0, 0, 0, 0.12);
color: rgba(0, 0, 0, 0.38);
}
.analyze-count { margin-left: 4px; font-size: 0.85em; opacity: 0.9; }
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
.league-panel {
margin-bottom: 16px;
border-radius: 10px !important;
overflow: hidden;
}
.flag-icon { margin-right: 8px; color: #f9a825; }
.country { font-weight: 400; }
.match-card {
margin: 12px 0;
padding: 16px;
border-left: 4px solid #1565c0;
transition: box-shadow 0.15s, border-color 0.15s, background 0.15s;
}
.match-card.selected {
border-left-color: #6a1b9a;
background: #faf5fd;
box-shadow: 0 0 0 2px rgba(106, 27, 154, 0.25);
}
.select-box { margin-right: 2px; }
.match-head {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.kickoff {
font-size: 1.1rem;
font-weight: 600;
background: #eef3fb;
padding: 4px 10px;
border-radius: 6px;
}
.teams {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.05rem;
flex: 1;
}
.team-name { font-weight: 500; }
.score { font-weight: 700; font-size: 1.15rem; }
.vs { color: rgba(0,0,0,0.4); font-style: italic; }
.status-badge {
padding: 3px 10px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
}
.status-scheduled { background: #1976d2; }
.status-live { background: #e53935; animation: pulse 1.4s infinite; }
.status-finished { background: #2e7d32; }
.status-cancelled { background: #757575; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
.match-meta {
display: flex;
gap: 18px;
margin: 10px 0 4px;
font-size: 0.85rem;
flex-wrap: wrap;
}
.match-meta span { display: inline-flex; align-items: center; gap: 4px; }
.match-meta mat-icon { font-size: 18px; width: 18px; height: 18px; }
.match-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 8px 0 2px;
flex-wrap: wrap;
}
.history-note {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
}
.history-note mat-icon { font-size: 17px; width: 17px; height: 17px; }
.action-buttons { display: inline-flex; gap: 8px; flex-wrap: wrap; }
.predict-btn {
background: linear-gradient(135deg, #6a1b9a, #8e24aa);
color: #fff;
}
.team-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 14px;
}
@media (max-width: 800px) {
.team-panels { grid-template-columns: 1fr; }
}
.team-panel {
background: #f8fafc;
border: 1px solid rgba(0,0,0,0.06);
border-radius: 8px;
padding: 12px;
}
.team-panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
gap: 8px;
}
.team-panel-name { font-weight: 600; }
.muted .small, .small { font-size: 0.8rem; }
.no-history {
display: flex;
align-items: center;
gap: 6px;
color: rgba(0,0,0,0.5);
font-style: italic;
padding: 8px 0;
}
.no-history mat-icon { font-size: 18px; width: 18px; height: 18px; }
.stat-row.goals {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
margin-bottom: 10px;
}
.stat-row.goals mat-icon { color: #1565c0; font-size: 20px; width: 20px; height: 20px; }
.averages-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 12px;
}
@media (max-width: 500px) { .averages-grid { grid-template-columns: repeat(2, 1fr); } }
.avg {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.85rem;
}
.avg .muted { font-size: 0.7rem; }
.form-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.form-chip {
width: 26px;
height: 26px;
border-radius: 50%;
color: #fff;
font-size: 0.75rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s;
}
.form-chip:hover {
transform: translateY(-1px) scale(1.08);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
}
.form-chip:focus-visible {
outline: 2px solid #1565c0;
outline-offset: 2px;
}
.chip-win { background: var(--bookie-win); }
.chip-draw { background: var(--bookie-draw); }
.chip-loss { background: var(--bookie-loss); }
.scorer-table { width: 100%; border-collapse: collapse; margin-top: 4px; }
.scorer-table td { padding: 3px 4px; border-bottom: 1px solid rgba(0,0,0,0.05); font-size: 0.85rem; }
.scorer-name { width: 100%; }
.league-model-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 16px 12px;
padding: 10px 14px;
background: #f3f6fb;
border-radius: 8px;
font-size: 0.85rem;
mat-icon { font-size: 18px; width: 18px; height: 18px; color: #5e35b1; }
}
.model-label { font-weight: 600; color: #3949ab; }
.model-chip {
background: #fff;
border: 1px solid rgba(94, 53, 177, 0.25);
border-radius: 4px;
padding: 2px 8px;
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
}
.odds-block, .extra-odds-block {
margin: 10px 0 14px;
padding: 10px 12px;
background: #fafafa;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.odds-block .small, .extra-odds-block .small {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
}
.odds-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.odds-cell {
text-align: center;
padding: 8px;
border-radius: 6px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
b { display: block; font-size: 1.1rem; margin: 2px 0; }
.implied { font-size: 0.75rem; color: rgba(0, 0, 0, 0.5); }
}
.home-odds b { color: #1565c0; }
.draw-odds b { color: #6a1b9a; }
.away-odds b { color: #e65100; }
.totals-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.totals-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
padding: 4px 10px;
font-size: 0.85rem;
.implied { font-size: 0.75rem; color: rgba(0, 0, 0, 0.5); }
}
.extra-odds-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
th, td { padding: 4px 8px; border-bottom: 1px solid rgba(0, 0, 0, 0.06); text-align: left; }
.num-col { text-align: right; }
}
.strength-row, .alias-row {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0 8px;
font-size: 0.85rem;
mat-icon { font-size: 16px; width: 16px; height: 16px; color: #5e35b1; }
}
.alias-row mat-icon { color: #78909c; font-size: 14px; width: 14px; height: 14px; }
.llm-meta { margin-top: 4px; color: #5e35b1; }
.pred-block {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.pred-block > .small { display: block; margin-bottom: 6px; }
.pred-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.pred-table th,
.pred-table td {
padding: 5px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
text-align: left;
}
.pred-table thead th {
font-size: 0.72rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.55);
text-transform: uppercase;
letter-spacing: 0.03em;
background: #fff;
}
.pred-table tbody tr:last-child td { border-bottom: none; }
.pred-table .num-col { text-align: right; width: 72px; font-weight: 600; }
.pred-table tbody td:first-child { color: rgba(0, 0, 0, 0.65); }
.pred-table .llm-col { color: #5e35b1; }
.pred-table .openai-col { color: #e65100; }
.pred-table .openai-val { color: #bf360c; }
.pred-table .actual-col { color: #00695c; }
.pred-table .actual-val { color: #00695c; font-weight: 700; }
.openai-meta { margin-top: 6px; }
/* Skeleton */
.skeleton-list { display: flex; flex-direction: column; gap: 16px; }
.skeleton-card { padding: 16px; }
.sk { background: linear-gradient(90deg, #eceff3 25%, #f6f8fa 37%, #eceff3 63%); background-size: 400% 100%; animation: shimmer 1.4s ease infinite; border-radius: 6px; }
.sk-title { height: 22px; width: 40%; margin-bottom: 12px; }
.sk-line { height: 14px; width: 100%; margin-bottom: 8px; }
.sk-line.short { width: 60%; }
.sk-panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 12px; }
.sk-panel { height: 140px; }
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
.state-card {
text-align: center;
padding: 48px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.state-icon { font-size: 56px; width: 56px; height: 56px; color: rgba(0,0,0,0.3); }

View File

@@ -0,0 +1,230 @@
import { Component, computed, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatBadgeModule } from '@angular/material/badge';
import { MatDialog } from '@angular/material/dialog';
import { DatePipe, DecimalPipe } from '@angular/common';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ApiService } from '../../core/services/api.service';
import { FormResult, MatchReport, PreMatchReport, TeamReport } from '../../core/models/models';
import { exportReportCsv, exportReportPdf } from '../../core/utils/report-export';
import { countAnalyzableMatches, exportPredictionAnalysisPdf } from '../../core/utils/prediction-analysis-export';
import { MatchDetailsDialogComponent } from './match-details-dialog.component';
import { HeadToHeadDialogComponent } from './head-to-head-dialog.component';
import { PredictionDialogComponent } from './prediction-dialog.component';
@Component({
selector: 'app-pre-match',
imports: [
ReactiveFormsModule, DatePipe, DecimalPipe,
MatCardModule, MatFormFieldModule, MatInputModule, MatDatepickerModule, MatButtonModule,
MatIconModule, MatChipsModule, MatExpansionModule, MatProgressSpinnerModule, MatTooltipModule,
MatSelectModule, MatCheckboxModule, MatBadgeModule,
],
templateUrl: './pre-match.component.html',
styleUrl: './pre-match.component.scss',
})
export class PreMatchComponent {
private api = inject(ApiService);
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
readonly dateControl = new FormControl<Date>(new Date(), { nonNullable: true });
readonly seasonsBackControl = new FormControl<number>(0, { nonNullable: true });
readonly loading = signal(false);
readonly analyzing = signal(false);
readonly report = signal<PreMatchReport | null>(null);
readonly errored = signal(false);
readonly hasMatches = computed(() => (this.report()?.matchCount ?? 0) > 0);
readonly analyzableCount = computed(() => {
const r = this.report();
return r ? countAnalyzableMatches(r) : 0;
});
readonly skeletons = [0, 1, 2];
/** Match ids selected for batch prediction. */
readonly selected = signal<Set<number>>(new Set());
readonly selectedCount = computed(() => this.selected().size);
readonly seasonsBackOptions = [
{ value: 0, label: 'Current season only' },
{ value: 1, label: 'Current + 1 previous' },
{ value: 2, label: 'Current + 2 previous' },
{ value: 3, label: 'Current + 3 previous' },
{ value: 5, label: 'Current + 5 previous' },
];
readonly statusLabels: Record<string, string> = {
scheduled: 'Scheduled', live: 'Live', finished: 'Finished', cancelled: 'Cancelled',
};
constructor() {
this.load();
}
private toIso(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
load() {
this.loading.set(true);
this.errored.set(false);
this.report.set(null);
this.selected.set(new Set());
const iso = this.toIso(this.dateControl.value);
this.api.getPreMatchReport(iso, this.seasonsBackControl.value).subscribe({
next: (r) => { this.report.set(r); this.loading.set(false); },
error: () => { this.errored.set(true); this.loading.set(false); },
});
}
seasonsLabel(m: MatchReport): string {
const s = m.seasonsIncluded;
if (!s || s.length === 0) return '';
return s.length === 1 ? `Season ${s[0]}` : `Seasons ${s.join(', ')}`;
}
chipClass(result: string): string {
return result === 'W' ? 'chip-win' : result === 'L' ? 'chip-loss' : 'chip-draw';
}
private static readonly MONTHS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
formTooltip(f: FormResult): string {
const d = new Date(f.kickoffAt);
const date = `${String(d.getDate()).padStart(2, '0')} ${PreMatchComponent.MONTHS[d.getMonth()]} ${d.getFullYear()}`;
const venue = f.isHome ? 'home vs' : 'away at';
const outcome = f.result === 'W' ? 'Won' : f.result === 'L' ? 'Lost' : 'Drew';
return `${date} · ${venue} ${f.opponentName} · ${outcome} ${f.goalsFor}-${f.goalsAgainst} · click for stats`;
}
openMatch(matchId: number) {
this.dialog.open(MatchDetailsDialogComponent, {
data: { matchId },
width: '780px',
maxWidth: '95vw',
maxHeight: '90vh',
autoFocus: false,
});
}
openHeadToHead(m: MatchReport) {
this.dialog.open(HeadToHeadDialogComponent, {
data: {
teamAId: m.homeTeamId,
teamBId: m.awayTeamId,
teamAName: m.homeTeamName,
teamBName: m.awayTeamName,
beforeMatchId: m.matchId,
},
width: '560px',
maxWidth: '95vw',
autoFocus: false,
});
}
isSelected(matchId: number): boolean {
return this.selected().has(matchId);
}
toggleSelected(matchId: number) {
const next = new Set(this.selected());
if (next.has(matchId)) next.delete(matchId);
else next.add(matchId);
this.selected.set(next);
}
clearSelection() {
this.selected.set(new Set());
}
selectAll() {
const ids = new Set<number>();
for (const g of this.report()?.groups ?? []) {
for (const m of g.matches) ids.add(m.matchId);
}
this.selected.set(ids);
}
openPrediction(m: MatchReport) {
this.openPredictionFor([m.matchId]);
}
predictSelected() {
const ids = [...this.selected()];
if (ids.length) this.openPredictionFor(ids);
}
private openPredictionFor(matchIds: number[]) {
this.dialog.open(PredictionDialogComponent, {
data: { matchIds },
width: matchIds.length > 1 ? '680px' : '600px',
maxWidth: '95vw',
maxHeight: '92vh',
autoFocus: false,
});
}
statusClass(status: string): string {
return `status-${status}`;
}
goalsSummary(t: TeamReport): string {
const g = t.goalsForAgainst;
return g.hasData ? `${g.avgGoalsFor} scored / ${g.avgGoalsAgainst} conceded per match` : 'no historical data';
}
extraMarketLabel(market: string): string {
switch (market) {
case 'corners_total': return 'Corners O/U';
case 'cards_total': return 'Cards O/U';
default: return market;
}
}
exportCsv() { const r = this.report(); if (r) exportReportCsv(r); }
async exportPdf() { const r = this.report(); if (r) await exportReportPdf(r); }
async analyze() {
const r = this.report();
if (!r || this.analyzing()) return;
if (this.analyzableCount() === 0) {
this.snack.open(
'No finished matches with complete LLM, OpenAI and actual data for both teams.',
'OK',
{ duration: 5000 },
);
return;
}
this.analyzing.set(true);
try {
const { included, skipped } = await exportPredictionAnalysisPdf(r);
this.snack.open(
`Analysis PDF downloaded (${included} match${included === 1 ? '' : 'es'}, ${skipped} skipped).`,
'OK',
{ duration: 4000 },
);
} catch {
this.snack.open('Failed to generate analysis PDF.', 'Dismiss', { duration: 5000 });
} finally {
this.analyzing.set(false);
}
}
}

View File

@@ -0,0 +1,87 @@
<div class="dlg-header">
<div class="comp">
<mat-icon>insights</mat-icon>
{{ isBatch ? 'Match projections' : 'Match projection' }}
@if (isBatch && !loading()) {
<span class="muted">· {{ predictions().length }} matches</span>
}
</div>
<button mat-icon-button mat-dialog-close class="close-btn" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
@if (loading()) {
<div class="dlg-loading">
<mat-spinner diameter="40"></mat-spinner>
<p class="muted">Crunching historical data and asking the model…</p>
</div>
} @else if (errored()) {
<div class="empty">
<mat-icon>cloud_off</mat-icon>
<p class="muted">{{ errorMessage() }}</p>
</div>
} @else if (!predictions().length) {
<div class="empty">
<mat-icon>search_off</mat-icon>
<p class="muted">No projection could be produced for the selected match(es).</p>
</div>
} @else {
@for (p of predictions(); track p.matchId) {
<section class="pred-block">
<div class="head-line">
<span>{{ p.homeTeam }} vs {{ p.awayTeam }}</span>
<span class="muted">— {{ p.league }} · {{ p.kickoffAt | date: 'EEE d MMM y, HH:mm' }}</span>
</div>
<table class="pred-table">
<thead>
<tr>
<th class="metric-col">Expected</th>
<th>{{ p.homeTeam }}</th>
<th>{{ p.awayTeam }}</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.key) {
<tr>
<td class="metric-col"><mat-icon>{{ row.icon }}</mat-icon>{{ row.label }}</td>
<td class="mono">{{ cell(p.home[row.key]) }}</td>
<td class="mono">{{ cell(p.away[row.key]) }}</td>
</tr>
}
</tbody>
</table>
<div class="conf-line">
<span class="muted">Confidence</span>
<span class="conf-badge" [class]="confidenceClass(p)">{{ p.confidence }}</span>
</div>
<p class="reasoning">{{ p.reasoning }}</p>
@if (p.flags.length) {
<div class="flags">
@for (f of p.flags; track $index) {
<div class="flag"><mat-icon>warning</mat-icon>{{ f }}</div>
}
</div>
}
</section>
}
<p class="disclaimer muted">
Statistical projection for analytical purposes only — not a guaranteed outcome. Generated by {{ predictions()[0].model }}.
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!loading() && !errored() && predictions().length) {
<button mat-stroked-button (click)="exportPdf()">
<mat-icon>picture_as_pdf</mat-icon> Export PDF
</button>
}
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,60 @@
.dlg-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 16px 0;
}
.comp { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.comp mat-icon { font-size: 20px; width: 20px; height: 20px; color: #6a1b9a; }
.close-btn { margin: -8px -8px 0 0; }
.dlg-loading {
display: flex; flex-direction: column; align-items: center; gap: 14px;
padding: 40px; min-width: 420px; text-align: center;
}
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px; min-width: 380px; text-align: center; }
.empty mat-icon { font-size: 40px; width: 40px; height: 40px; color: rgba(0,0,0,0.3); }
.pred-block { padding: 4px 0; }
.pred-block + .pred-block {
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid rgba(106, 27, 154, 0.15);
}
.head-line { display: flex; flex-direction: column; margin: 4px 0 12px; font-weight: 600; }
.head-line .muted { font-weight: 400; font-size: 0.82rem; }
.pred-table { width: 100%; border-collapse: collapse; }
.pred-table th, .pred-table td { padding: 8px 10px; text-align: center; border-bottom: 1px solid rgba(0,0,0,0.08); }
.pred-table thead th { font-size: 0.82rem; color: rgba(0,0,0,0.6); font-weight: 600; }
.pred-table .metric-col { text-align: left; }
.pred-table td.metric-col {
display: flex; align-items: center; gap: 6px; font-weight: 500; border-bottom: 1px solid rgba(0,0,0,0.08);
}
.pred-table td.metric-col mat-icon { font-size: 18px; width: 18px; height: 18px; color: #6a1b9a; }
.pred-table tbody tr:nth-child(odd) td { background: #faf7fd; }
.conf-line { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; }
.conf-badge {
padding: 3px 12px; border-radius: 12px; font-size: 0.72rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.4px; color: #fff;
}
.conf-high { background: #2e7d32; }
.conf-medium { background: #f9a825; }
.conf-low { background: #c62828; }
.section-title {
font-size: 0.95rem; font-weight: 600; margin: 16px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid rgba(0,0,0,0.08);
}
.reasoning { margin: 0; line-height: 1.5; font-size: 0.9rem; }
.flags { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.flag {
display: flex; align-items: center; gap: 6px; font-size: 0.82rem;
background: #fff8e1; border: 1px solid #ffe082; border-radius: 6px; padding: 6px 10px;
}
.flag mat-icon { font-size: 18px; width: 18px; height: 18px; color: #f9a825; }
.disclaimer { margin: 16px 0 0; font-size: 0.72rem; font-style: italic; }

View File

@@ -0,0 +1,74 @@
import { Component, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ApiService } from '../../core/services/api.service';
import { MatchPrediction, MetricEstimate, TeamPrediction } from '../../core/models/models';
import { exportPredictionsPdf } from '../../core/utils/report-export';
interface DialogData { matchIds: number[]; }
interface PredRow {
label: string;
icon: string;
key: keyof TeamPrediction;
}
@Component({
selector: 'app-prediction-dialog',
imports: [
DatePipe, MatDialogModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule,
],
templateUrl: './prediction-dialog.component.html',
styleUrl: './prediction-dialog.component.scss',
})
export class PredictionDialogComponent {
private api = inject(ApiService);
readonly data = inject<DialogData>(MAT_DIALOG_DATA);
readonly loading = signal(true);
readonly errored = signal(false);
readonly errorMessage = signal('');
readonly predictions = signal<MatchPrediction[]>([]);
readonly rows: PredRow[] = [
{ label: 'Goals', icon: 'sports_soccer', key: 'goals' },
{ label: 'Shots on target', icon: 'my_location', key: 'shotsOnTarget' },
{ label: 'Corners', icon: 'flag', key: 'corners' },
{ label: 'Fouls', icon: 'front_hand', key: 'fouls' },
{ label: 'Yellow cards', icon: 'style', key: 'yellowCards' },
{ label: 'Red cards', icon: 'style', key: 'redCards' },
];
get isBatch(): boolean {
return this.data.matchIds.length > 1;
}
constructor() {
this.api.predictMatches(this.data.matchIds).subscribe({
next: (p) => { this.predictions.set(p); this.loading.set(false); },
error: (err) => {
this.errorMessage.set(err?.error?.detail || 'The prediction service is unavailable. Check the OpenAI API key configuration and try again.');
this.errored.set(true);
this.loading.set(false);
},
});
}
confidenceClass(p: MatchPrediction): string {
const c = p.confidence?.toLowerCase() ?? '';
return c === 'high' ? 'conf-high' : c === 'medium' ? 'conf-medium' : 'conf-low';
}
cell(m: MetricEstimate): string {
const r = (n: number) => (Math.round(n * 10) / 10).toString();
return `${r(m.estimate)} (${r(m.low)}${r(m.high)})`;
}
async exportPdf() {
const p = this.predictions();
if (p.length) await exportPredictionsPdf(p);
}
}

View File

@@ -0,0 +1,154 @@
import { Component, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTableModule } from '@angular/material/table';
import { MatChipsModule } from '@angular/material/chips';
import { ApiService } from '../../core/services/api.service';
import { PlayerStats } from '../../core/models/models';
import { MatchDetailsDialogComponent } from '../pre-match/match-details-dialog.component';
interface DialogData { playerId: number; }
@Component({
selector: 'app-player-stats-dialog',
imports: [
DatePipe, MatDialogModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule,
MatTooltipModule, MatTableModule, MatChipsModule,
],
template: `
<h2 mat-dialog-title>
<mat-icon>person</mat-icon>
{{ stats()?.fullName ?? 'Player statistics' }}
</h2>
<mat-dialog-content>
@if (loading()) {
<div class="center"><mat-spinner diameter="40"></mat-spinner></div>
} @else if (errored()) {
<div class="center muted"><mat-icon>error_outline</mat-icon> Could not load player statistics.</div>
} @else if (stats(); as s) {
<div class="meta">
@if (s.primaryPosition) { <span class="chip">{{ s.primaryPosition }}</span> }
@if (s.nationality) { <span class="muted">{{ s.nationality }}</span> }
@if (s.currentTeam) { <span class="muted">· {{ s.currentTeam }}</span> }
@if (s.birthDate) { <span class="muted">· born {{ s.birthDate | date: 'mediumDate' }}</span> }
</div>
@if (!s.hasData) {
<div class="center muted"><mat-icon>info</mat-icon> No goals, assists or cards recorded yet.</div>
} @else {
<div class="tiles">
<div class="tile"><span class="num">{{ s.totalGoals }}</span><span class="lbl">Goals</span></div>
<div class="tile"><span class="num">{{ s.totalAssists }}</span><span class="lbl">Assists</span></div>
<div class="tile"><span class="num">{{ s.matchesScored }}</span><span class="lbl">Matches scored</span></div>
<div class="tile"><span class="num yellow">{{ s.yellowCards }}</span><span class="lbl">Yellow</span></div>
<div class="tile"><span class="num red">{{ s.redCards }}</span><span class="lbl">Red</span></div>
</div>
<div class="breakdown muted">
Open play: {{ s.goalsOpenPlay }} · Penalties: {{ s.goalsPenalty }} · Own goals: {{ s.goalsOwn }}
</div>
@if (s.seasons.length) {
<h3>By season</h3>
<table mat-table [dataSource]="s.seasons" class="mini">
<ng-container matColumnDef="season">
<th mat-header-cell *matHeaderCellDef>Season</th>
<td mat-cell *matCellDef="let r">{{ r.seasonName }}</td>
</ng-container>
<ng-container matColumnDef="league">
<th mat-header-cell *matHeaderCellDef>League</th>
<td mat-cell *matCellDef="let r">{{ r.leagueName }}</td>
</ng-container>
<ng-container matColumnDef="goals">
<th mat-header-cell *matHeaderCellDef class="num-col">G</th>
<td mat-cell *matCellDef="let r" class="num-col">{{ r.goals }}</td>
</ng-container>
<ng-container matColumnDef="assists">
<th mat-header-cell *matHeaderCellDef class="num-col">A</th>
<td mat-cell *matCellDef="let r" class="num-col">{{ r.assists }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="seasonCols"></tr>
<tr mat-row *matRowDef="let r; columns: seasonCols"></tr>
</table>
}
@if (s.recentGoals.length) {
<h3>Recent goals</h3>
<ul class="goals">
@for (g of s.recentGoals; track g.matchId + '-' + g.minute) {
<li (click)="openMatch(g.matchId)" matTooltip="Open match statistics">
<span class="date">{{ g.kickoffAt | date: 'mediumDate' }}</span>
<span class="fixture">{{ g.homeTeamName }} vs {{ g.awayTeamName }}</span>
<span class="min">{{ minuteLabel(g.minute, g.addedTime) }}</span>
@if (g.goalType !== 'open_play') { <span class="type">{{ g.goalType }}</span> }
</li>
}
</ul>
}
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
`,
styles: [`
:host { display: block; }
h2[mat-dialog-title] { display: flex; align-items: center; gap: 8px; }
h3 { margin: 18px 0 6px; font-size: 0.95rem; }
.center { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 0; }
.muted { color: rgba(0,0,0,0.55); }
.meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.chip { background: #e3f2fd; color: #1565c0; border-radius: 12px; padding: 2px 10px; font-size: 0.8rem; font-weight: 600; }
.tiles { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
.tile { display: flex; flex-direction: column; align-items: center; background: #f5f5f7; border-radius: 10px; padding: 12px 6px; }
.tile .num { font-size: 1.5rem; font-weight: 700; }
.tile .num.yellow { color: #c9a800; }
.tile .num.red { color: #c62828; }
.tile .lbl { font-size: 0.72rem; color: rgba(0,0,0,0.6); text-align: center; }
.breakdown { margin-top: 10px; font-size: 0.82rem; }
table.mini { width: 100%; }
.num-col { text-align: right; width: 48px; }
ul.goals { list-style: none; margin: 0; padding: 0; }
ul.goals li { display: flex; align-items: center; gap: 10px; padding: 7px 4px; border-bottom: 1px solid #eee; cursor: pointer; font-size: 0.85rem; }
ul.goals li:hover { background: #f0f7ff; }
ul.goals .date { color: rgba(0,0,0,0.55); min-width: 96px; }
ul.goals .fixture { flex: 1; }
ul.goals .min { font-weight: 600; }
ul.goals .type { background: #fff3e0; color: #e65100; border-radius: 8px; padding: 1px 6px; font-size: 0.72rem; }
`],
})
export class PlayerStatsDialogComponent {
private api = inject(ApiService);
private dialog = inject(MatDialog);
readonly data = inject<DialogData>(MAT_DIALOG_DATA);
readonly loading = signal(true);
readonly errored = signal(false);
readonly stats = signal<PlayerStats | null>(null);
readonly seasonCols = ['season', 'league', 'goals', 'assists'];
constructor() {
this.api.getPlayerStats(this.data.playerId).subscribe({
next: (s) => { this.stats.set(s); this.loading.set(false); },
error: () => { this.errored.set(true); this.loading.set(false); },
});
}
minuteLabel(minute: number, added: number): string {
return added > 0 ? `${minute}+${added}'` : `${minute}'`;
}
openMatch(matchId: number) {
this.dialog.open(MatchDetailsDialogComponent, {
data: { matchId }, width: '640px', maxWidth: '95vw', autoFocus: false,
});
}
}

View File

@@ -0,0 +1,206 @@
import { Component, computed, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTableModule } from '@angular/material/table';
import { ApiService } from '../../core/services/api.service';
import { FormResult, TeamStats } from '../../core/models/models';
import { MatchDetailsDialogComponent } from '../pre-match/match-details-dialog.component';
interface DialogData { teamId: number; }
interface AvgRow { label: string; value: string; }
@Component({
selector: 'app-team-stats-dialog',
imports: [
DatePipe, MatDialogModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule,
MatTooltipModule, MatTableModule,
],
template: `
<h2 mat-dialog-title>
<mat-icon>groups</mat-icon>
{{ stats()?.name ?? 'Team statistics' }}
</h2>
<mat-dialog-content>
@if (loading()) {
<div class="center"><mat-spinner diameter="40"></mat-spinner></div>
} @else if (errored()) {
<div class="center muted"><mat-icon>error_outline</mat-icon> Could not load team statistics.</div>
} @else if (stats(); as s) {
<div class="meta muted">
@if (s.city) { <span>{{ s.city }}</span> }
@if (s.stadium) { <span>· {{ s.stadium }}</span> }
</div>
@if (!s.hasData) {
<div class="center muted"><mat-icon>info</mat-icon> No finished matches recorded yet.</div>
} @else {
<div class="tiles">
<div class="tile"><span class="num">{{ s.played }}</span><span class="lbl">Played</span></div>
<div class="tile"><span class="num win">{{ s.wins }}</span><span class="lbl">Won</span></div>
<div class="tile"><span class="num draw">{{ s.draws }}</span><span class="lbl">Drawn</span></div>
<div class="tile"><span class="num loss">{{ s.losses }}</span><span class="lbl">Lost</span></div>
<div class="tile"><span class="num">{{ s.goalsFor }}:{{ s.goalsAgainst }}</span><span class="lbl">Goals</span></div>
<div class="tile"><span class="num">{{ s.winPct }}%</span><span class="lbl">Win rate</span></div>
</div>
@if (s.form.length) {
<h3>Form <span class="muted">(recent → latest)</span></h3>
<div class="form">
@for (f of s.form; track f.matchId) {
<span class="pill" [class.w]="f.result === 'W'" [class.d]="f.result === 'D'" [class.l]="f.result === 'L'"
(click)="openMatch(f.matchId)" [matTooltip]="formTip(f)">{{ f.result }}</span>
}
</div>
}
@if (avgRows().length) {
<h3>Average per match</h3>
<div class="avg-grid">
@for (r of avgRows(); track r.label) {
<div class="avg"><span class="v">{{ r.value }}</span><span class="k">{{ r.label }}</span></div>
}
</div>
}
@if (s.topScorers.length) {
<h3>Top scorers</h3>
<table mat-table [dataSource]="s.topScorers" class="mini">
<ng-container matColumnDef="player">
<th mat-header-cell *matHeaderCellDef>Player</th>
<td mat-cell *matCellDef="let r">{{ r.playerName }}</td>
</ng-container>
<ng-container matColumnDef="goals">
<th mat-header-cell *matHeaderCellDef class="num-col">G</th>
<td mat-cell *matCellDef="let r" class="num-col">{{ r.goals }}</td>
</ng-container>
<ng-container matColumnDef="assists">
<th mat-header-cell *matHeaderCellDef class="num-col">A</th>
<td mat-cell *matCellDef="let r" class="num-col">{{ r.assists }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="scorerCols"></tr>
<tr mat-row *matRowDef="let r; columns: scorerCols"></tr>
</table>
}
@if (s.seasons.length) {
<h3>By season</h3>
<table mat-table [dataSource]="s.seasons" class="mini">
<ng-container matColumnDef="season">
<th mat-header-cell *matHeaderCellDef>Season</th>
<td mat-cell *matCellDef="let r">{{ r.seasonName }}</td>
</ng-container>
<ng-container matColumnDef="record">
<th mat-header-cell *matHeaderCellDef class="num-col">W-D-L</th>
<td mat-cell *matCellDef="let r" class="num-col">{{ r.wins }}-{{ r.draws }}-{{ r.losses }}</td>
</ng-container>
<ng-container matColumnDef="gf">
<th mat-header-cell *matHeaderCellDef class="num-col">Goals</th>
<td mat-cell *matCellDef="let r" class="num-col">{{ r.goalsFor }}:{{ r.goalsAgainst }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="seasonCols"></tr>
<tr mat-row *matRowDef="let r; columns: seasonCols"></tr>
</table>
}
@if (s.recentMatches.length) {
<h3>Recent matches</h3>
<ul class="matches">
@for (m of s.recentMatches; track m.matchId) {
<li (click)="openMatch(m.matchId)" matTooltip="Open match statistics">
<span class="date">{{ m.kickoffAt | date: 'mediumDate' }}</span>
<span class="fixture">{{ m.homeTeamName }} {{ m.homeScoreFt }}{{ m.awayScoreFt }} {{ m.awayTeamName }}</span>
</li>
}
</ul>
}
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
`,
styles: [`
:host { display: block; }
h2[mat-dialog-title] { display: flex; align-items: center; gap: 8px; }
h3 { margin: 18px 0 6px; font-size: 0.95rem; }
.center { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 0; }
.muted { color: rgba(0,0,0,0.55); }
.meta { display: flex; gap: 6px; margin-bottom: 12px; }
.tiles { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.tile { display: flex; flex-direction: column; align-items: center; background: #f5f5f7; border-radius: 10px; padding: 10px 4px; }
.tile .num { font-size: 1.25rem; font-weight: 700; }
.tile .num.win { color: #2e7d32; }
.tile .num.draw { color: #616161; }
.tile .num.loss { color: #c62828; }
.tile .lbl { font-size: 0.68rem; color: rgba(0,0,0,0.6); text-align: center; }
.form { display: flex; gap: 6px; }
.pill { width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.8rem; color: #fff; cursor: pointer; background: #9e9e9e; }
.pill.w { background: #2e7d32; } .pill.d { background: #9e9e9e; } .pill.l { background: #c62828; }
.avg-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.avg { background: #f5f5f7; border-radius: 8px; padding: 8px; display: flex; flex-direction: column; align-items: center; }
.avg .v { font-weight: 700; }
.avg .k { font-size: 0.68rem; color: rgba(0,0,0,0.6); text-align: center; }
table.mini { width: 100%; }
.num-col { text-align: right; }
ul.matches { list-style: none; margin: 0; padding: 0; }
ul.matches li { display: flex; gap: 10px; padding: 7px 4px; border-bottom: 1px solid #eee; cursor: pointer; font-size: 0.85rem; }
ul.matches li:hover { background: #f0f7ff; }
ul.matches .date { color: rgba(0,0,0,0.55); min-width: 96px; }
`],
})
export class TeamStatsDialogComponent {
private api = inject(ApiService);
private dialog = inject(MatDialog);
readonly data = inject<DialogData>(MAT_DIALOG_DATA);
readonly loading = signal(true);
readonly errored = signal(false);
readonly stats = signal<TeamStats | null>(null);
readonly scorerCols = ['player', 'goals', 'assists'];
readonly seasonCols = ['season', 'record', 'gf'];
readonly avgRows = computed<AvgRow[]>(() => {
const a = this.stats()?.averages;
if (!a || !a.matchesPlayed) return [];
const v = (x: number | null | undefined, suffix = '') => (x == null ? '—' : `${x}${suffix}`);
return [
{ label: 'Possession', value: v(a.possession, '%') },
{ label: 'Shots', value: v(a.shotsTotal) },
{ label: 'On target', value: v(a.shotsOnTarget) },
{ label: 'Corners', value: v(a.corners) },
{ label: 'Fouls', value: v(a.fouls) },
{ label: 'Offsides', value: v(a.offsides) },
{ label: 'Yellow', value: v(a.yellowCards) },
{ label: 'Red', value: v(a.redCards) },
];
});
constructor() {
this.api.getTeamStats(this.data.teamId).subscribe({
next: (s) => { this.stats.set(s); this.loading.set(false); },
error: () => { this.errored.set(true); this.loading.set(false); },
});
}
formTip(f: FormResult): string {
const date = new Date(f.kickoffAt).toLocaleDateString();
const venue = f.isHome ? 'home vs' : 'away at';
const outcome = f.result === 'W' ? 'Won' : f.result === 'L' ? 'Lost' : 'Drew';
return `${date} · ${venue} ${f.opponentName} · ${outcome} ${f.goalsFor}-${f.goalsAgainst}`;
}
openMatch(matchId: number) {
this.dialog.open(MatchDetailsDialogComponent, {
data: { matchId }, width: '640px', maxWidth: '95vw', autoFocus: false,
});
}
}

View File

@@ -0,0 +1,37 @@
<mat-toolbar color="primary" class="app-toolbar">
<button mat-icon-button (click)="toggle()" aria-label="Toggle navigation">
<mat-icon>menu</mat-icon>
</button>
<span class="brand">
<mat-icon>sports_soccer</mat-icon>
Bookie
</span>
<span class="spacer"></span>
<button mat-button [matMenuTriggerFor]="userMenu">
<mat-icon>account_circle</mat-icon>
{{ username() }} ({{ role() }})
</button>
<mat-menu #userMenu="matMenu">
<button mat-menu-item (click)="logout()">
<mat-icon>logout</mat-icon>
<span>Log out</span>
</button>
</mat-menu>
</mat-toolbar>
<mat-sidenav-container class="app-container">
<mat-sidenav [opened]="opened()" mode="side" class="app-sidenav">
<mat-nav-list>
@for (item of nav; track item.path) {
<a mat-list-item [routerLink]="item.path" routerLinkActive="active-link">
<mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
<span matListItemTitle>{{ item.label }}</span>
</a>
}
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content class="app-content">
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>

View File

@@ -0,0 +1,33 @@
:host { display: block; height: 100%; }
.app-toolbar {
position: sticky;
top: 0;
z-index: 10;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-left: 8px;
}
.app-container {
height: calc(100% - 64px);
}
.app-sidenav {
width: 240px;
border-right: 1px solid rgba(0, 0, 0, 0.08);
}
.active-link {
background: rgba(0, 0, 0, 0.06);
font-weight: 600;
}
.app-content {
background: #f4f6f9;
}

View File

@@ -0,0 +1,47 @@
import { Component, inject, signal } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive, Router } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { AuthService } from '../core/services/auth.service';
interface NavItem { path: string; label: string; icon: string; }
@Component({
selector: 'app-shell',
imports: [
RouterOutlet, RouterLink, RouterLinkActive,
MatToolbarModule, MatSidenavModule, MatListModule, MatIconModule, MatButtonModule, MatMenuModule,
],
templateUrl: './shell.component.html',
styleUrl: './shell.component.scss',
})
export class ShellComponent {
private auth = inject(AuthService);
private router = inject(Router);
readonly opened = signal(true);
readonly username = this.auth.username;
readonly role = this.auth.role;
readonly nav: NavItem[] = [
{ path: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
{ path: '/pre-match', label: 'Pre-Match Reports', icon: 'query_stats' },
{ path: '/matches', label: 'Matches', icon: 'sports_soccer' },
{ path: '/leagues', label: 'Leagues', icon: 'emoji_events' },
{ path: '/seasons', label: 'Seasons', icon: 'calendar_month' },
{ path: '/teams', label: 'Teams', icon: 'groups' },
{ path: '/players', label: 'Players', icon: 'person' },
{ path: '/contracts', label: 'Contracts', icon: 'description' },
];
toggle() { this.opened.update((v) => !v); }
logout() {
this.auth.logout();
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,25 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
export interface ConfirmData { title: string; message: string; confirmText?: string; }
@Component({
selector: 'app-confirm-dialog',
imports: [MatDialogModule, MatButtonModule, MatIconModule],
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>{{ data.message }}</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="ref.close(false)">Cancel</button>
<button mat-flat-button color="warn" (click)="ref.close(true)">
<mat-icon>delete</mat-icon> {{ data.confirmText ?? 'Delete' }}
</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialogComponent {
readonly data = inject<ConfirmData>(MAT_DIALOG_DATA);
readonly ref = inject(MatDialogRef<ConfirmDialogComponent>);
}

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiBaseUrl: '/api',
};

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:5210/api',
};

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BookieClient</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

6
BookieClient/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1,68 @@
@use '@angular/material' as mat;
html {
color-scheme: light;
@include mat.theme((
color: (
theme-type: light,
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
));
--bookie-win: #2e7d32;
--bookie-draw: #9e9e9e;
--bookie-loss: #c62828;
}
html, body {
height: 100%;
margin: 0;
font-family: Roboto, 'Helvetica Neue', sans-serif;
background: #f4f6f9;
}
* { box-sizing: border-box; }
.page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.page-header h1 {
margin: 0;
font-size: 1.6rem;
font-weight: 500;
}
.spacer { flex: 1 1 auto; }
.full-width { width: 100%; }
.mono { font-variant-numeric: tabular-nums; }
.muted { color: rgba(0, 0, 0, 0.55); }
.toolbar-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
table { width: 100%; }
.actions-cell { white-space: nowrap; text-align: right; }

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}