Initial commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user