Initial commit
This commit is contained in:
17
BookieClient/.editorconfig
Normal file
17
BookieClient/.editorconfig
Normal 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
44
BookieClient/.gitignore
vendored
Normal 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
12
BookieClient/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
BookieClient/.vscode/extensions.json
vendored
Normal file
4
BookieClient/.vscode/extensions.json
vendored
Normal 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
20
BookieClient/.vscode/launch.json
vendored
Normal 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
42
BookieClient/.vscode/tasks.json
vendored
Normal 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
59
BookieClient/README.md
Normal 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
79
BookieClient/angular.json
Normal 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
8363
BookieClient/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
BookieClient/package.json
Normal file
38
BookieClient/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
BookieClient/public/favicon.ico
Normal file
BIN
BookieClient/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
BookieClient/public/fonts/Roboto-Regular.ttf
Normal file
BIN
BookieClient/public/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
20
BookieClient/src/app/app.config.ts
Normal file
20
BookieClient/src/app/app.config.ts
Normal 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' } },
|
||||
],
|
||||
};
|
||||
29
BookieClient/src/app/app.routes.ts
Normal file
29
BookieClient/src/app/app.routes.ts
Normal 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: '' },
|
||||
];
|
||||
9
BookieClient/src/app/app.ts
Normal file
9
BookieClient/src/app/app.ts
Normal 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 {}
|
||||
19
BookieClient/src/app/core/guards/auth.guard.ts
Normal file
19
BookieClient/src/app/core/guards/auth.guard.ts
Normal 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;
|
||||
};
|
||||
33
BookieClient/src/app/core/interceptors/auth.interceptor.ts
Normal file
33
BookieClient/src/app/core/interceptors/auth.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
441
BookieClient/src/app/core/models/models.ts
Normal file
441
BookieClient/src/app/core/models/models.ts
Normal 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;
|
||||
}
|
||||
113
BookieClient/src/app/core/services/api.service.ts
Normal file
113
BookieClient/src/app/core/services/api.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
56
BookieClient/src/app/core/services/auth.service.ts
Normal file
56
BookieClient/src/app/core/services/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
BookieClient/src/app/core/utils/pdf-fonts.ts
Normal file
50
BookieClient/src/app/core/utils/pdf-fonts.ts
Normal 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' },
|
||||
};
|
||||
368
BookieClient/src/app/core/utils/prediction-analysis-export.ts
Normal file
368
BookieClient/src/app/core/utils/prediction-analysis-export.ts
Normal 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 };
|
||||
}
|
||||
203
BookieClient/src/app/core/utils/report-export.ts
Normal file
203
BookieClient/src/app/core/utils/report-export.ts
Normal 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);
|
||||
}
|
||||
48
BookieClient/src/app/features/auth/login.component.html
Normal file
48
BookieClient/src/app/features/auth/login.component.html
Normal 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 • user / user123
|
||||
</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
34
BookieClient/src/app/features/auth/login.component.scss
Normal file
34
BookieClient/src/app/features/auth/login.component.scss
Normal 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; }
|
||||
50
BookieClient/src/app/features/auth/login.component.ts
Normal file
50
BookieClient/src/app/features/auth/login.component.ts
Normal 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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
252
BookieClient/src/app/features/crud/crud-config.ts
Normal file
252
BookieClient/src/app/features/crud/crud-config.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
120
BookieClient/src/app/features/crud/crud-page.component.html
Normal file
120
BookieClient/src/app/features/crud/crud-page.component.html
Normal 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>
|
||||
50
BookieClient/src/app/features/crud/crud-page.component.scss
Normal file
50
BookieClient/src/app/features/crud/crud-page.component.scss
Normal 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; }
|
||||
255
BookieClient/src/app/features/crud/crud-page.component.ts
Normal file
255
BookieClient/src/app/features/crud/crud-page.component.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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}'`;
|
||||
}
|
||||
}
|
||||
421
BookieClient/src/app/features/pre-match/pre-match.component.html
Normal file
421
BookieClient/src/app/features/pre-match/pre-match.component.html
Normal 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"> — {{ 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>
|
||||
382
BookieClient/src/app/features/pre-match/pre-match.component.scss
Normal file
382
BookieClient/src/app/features/pre-match/pre-match.component.scss
Normal 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); }
|
||||
230
BookieClient/src/app/features/pre-match/pre-match.component.ts
Normal file
230
BookieClient/src/app/features/pre-match/pre-match.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
37
BookieClient/src/app/layout/shell.component.html
Normal file
37
BookieClient/src/app/layout/shell.component.html
Normal 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>
|
||||
33
BookieClient/src/app/layout/shell.component.scss
Normal file
33
BookieClient/src/app/layout/shell.component.scss
Normal 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;
|
||||
}
|
||||
47
BookieClient/src/app/layout/shell.component.ts
Normal file
47
BookieClient/src/app/layout/shell.component.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
25
BookieClient/src/app/shared/confirm-dialog.component.ts
Normal file
25
BookieClient/src/app/shared/confirm-dialog.component.ts
Normal 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>);
|
||||
}
|
||||
4
BookieClient/src/environments/environment.prod.ts
Normal file
4
BookieClient/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '/api',
|
||||
};
|
||||
4
BookieClient/src/environments/environment.ts
Normal file
4
BookieClient/src/environments/environment.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:5210/api',
|
||||
};
|
||||
16
BookieClient/src/index.html
Normal file
16
BookieClient/src/index.html
Normal 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
6
BookieClient/src/main.ts
Normal 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));
|
||||
68
BookieClient/src/styles.scss
Normal file
68
BookieClient/src/styles.scss
Normal 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; }
|
||||
14
BookieClient/tsconfig.app.json
Normal file
14
BookieClient/tsconfig.app.json
Normal 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"
|
||||
]
|
||||
}
|
||||
31
BookieClient/tsconfig.json
Normal file
31
BookieClient/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
BookieClient/tsconfig.spec.json
Normal file
14
BookieClient/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user