Initial commit

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

View File

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