Files
leaguerecorder/tauri-app/src/components/GameHistory.vue
Valentin Haudiquet b09f669e73
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m6s
tauri-app: fix end-of-game stats reading, pass raw json file to front
2026-03-26 23:42:31 +01:00

1063 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import type { GameHistoryItem, TimestampedEvent, ItemInfo, RawEndGameStats, EndGamePlayer } from "../types/timeline";
import {
getGameResult,
formatDuration,
formatKDA,
calculateKDA,
formatCSPerMin,
formatGoldPerMin,
formatNumber,
getChampionImageUrl,
getQueueDisplayName,
formatRelativeTime,
formatGameStartTime,
getSummonerSpellUrl,
getItemImageUrl,
} from "../types/timeline";
// Helper to get video timestamp in seconds from tuple format
function getVideoTimestampSecs(event: TimestampedEvent): number {
return event.video_timestamp[0];
}
const games = ref<GameHistoryItem[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const selectedGame = ref<GameHistoryItem | null>(null);
async function loadGameHistory() {
try {
loading.value = true;
error.value = null;
games.value = await invoke<GameHistoryItem[]>("get_game_history");
} catch (e) {
error.value = `Failed to load game history: ${e}`;
console.error("Failed to load game history:", e);
} finally {
loading.value = false;
}
}
function selectGame(game: GameHistoryItem) {
selectedGame.value = game;
}
function closeDetail() {
selectedGame.value = null;
}
// Helper to find local player from raw end game stats
function getLocalPlayer(stats: RawEndGameStats | null): EndGamePlayer | null {
if (!stats) return null;
// Try localPlayer field first (camelCase from API)
if (stats.localPlayer) {
return stats.localPlayer;
}
// Try teams
if (stats.teams) {
for (const team of stats.teams) {
if (team.players) {
for (const player of team.players) {
if (player.isLocalPlayer) {
return player;
}
}
}
}
}
// Try legacy players array
if (stats.players && stats.players.length > 0) {
return stats.players[0];
}
return null;
}
// Helper to get items array for display (6 slots + trinket)
// Items are now stored as raw item IDs in raw_end_game_stats
function getItemsArray(game: GameHistoryItem): (ItemInfo | null)[] {
const result: (ItemInfo | null)[] = [null, null, null, null, null, null, null];
const localPlayer = getLocalPlayer(game.raw_end_game_stats);
if (localPlayer && localPlayer.items) {
// Items are stored as an array of item IDs (up to 7 items: 6 main + 1 trinket)
for (let i = 0; i < Math.min(localPlayer.items.length, 7); i++) {
const itemId = localPlayer.items[i];
if (itemId && itemId > 0) {
// Slot 6 is trinket, slots 0-5 are main items
result[i] = {
itemId: itemId,
name: null,
slot: i
};
}
}
}
return result;
}
onMounted(() => {
loadGameHistory();
});
</script>
<template>
<div class="game-history">
<header class="header">
<h1>Match History</h1>
<button class="refresh-btn" @click="loadGameHistory" :disabled="loading">
<span v-if="loading"></span>
<span v-else></span>
</button>
</header>
<!-- Loading State -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>Loading match history...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error">
<p>{{ error }}</p>
<button @click="loadGameHistory">Retry</button>
</div>
<!-- Empty State -->
<div v-else-if="games.length === 0" class="empty">
<div class="empty-icon">🎮</div>
<h2>No Games Recorded</h2>
<p>Start playing to see your match history here.</p>
</div>
<!-- Game List - Single Column -->
<div v-else class="game-list">
<div
v-for="game in games"
:key="game.recording_id"
class="game-card"
:class="{
victory: getGameResult(game) === 'Victory',
defeat: getGameResult(game) === 'Defeat',
terminated: getGameResult(game) === 'Terminated'
}"
@click="selectGame(game)"
>
<!-- Result Banner -->
<div class="result-banner">
<span class="result-text">{{ getGameResult(game) }}</span>
</div>
<!-- Main Content -->
<div class="game-content">
<!-- Left: Champion Image -->
<div class="champion-section">
<div class="champion-image-wrapper">
<img
:src="getChampionImageUrl(game.champion)"
:alt="game.champion || 'Unknown Champion'"
class="champion-image"
@error="($event.target as HTMLImageElement).src = getChampionImageUrl(null)"
/>
<div class="champion-level" v-if="game.final_stats">
{{ Math.min(18, Math.floor(game.final_stats.game_duration / 60)) }}
</div>
</div>
<!-- Summoner Spells -->
<div class="summoner-spells">
<div class="spell-slot">
<img
v-if="game.summoner_spells"
:src="getSummonerSpellUrl(game.summoner_spells.spell1Id)"
:alt="game.summoner_spells.spell1Name || 'Spell 1'"
class="spell-image"
/>
<div v-else class="spell-placeholder"></div>
</div>
<div class="spell-slot">
<img
v-if="game.summoner_spells"
:src="getSummonerSpellUrl(game.summoner_spells.spell2Id)"
:alt="game.summoner_spells.spell2Name || 'Spell 2'"
class="spell-image"
/>
<div v-else class="spell-placeholder"></div>
</div>
</div>
</div>
<!-- Center: Game Info -->
<div class="game-info-section">
<div class="game-info-row">
<!-- Left: Queue Type & Time -->
<div class="game-info-left">
<div class="queue-type">
{{ getQueueDisplayName(game.queue_type, game.queue_id) }}
</div>
<div class="game-duration">
{{ formatDuration(game.duration_secs) }}
</div>
<div class="game-time">
{{ formatRelativeTime(game.start_time) }}
</div>
</div>
<!-- Right: KDA Score -->
<div class="kda-section" v-if="game.final_stats">
<span class="kda-values">
{{ formatKDA(game.final_stats) }}
</span>
<span class="kda-ratio" :class="{ perfect: game.final_stats.deaths === 0 }">
{{ calculateKDA(game.final_stats) }} KDA
</span>
</div>
<div class="kda-section" v-else>
<span class="kda-values">0/0/0</span>
<span class="kda-ratio">0.0 KDA</span>
</div>
</div>
</div>
<!-- Right: Key Stats Column + Items -->
<div class="stats-section" v-if="game.final_stats">
<!-- Key Stats Column -->
<div class="key-stats-column">
<div class="key-stat">
<span class="key-stat-value">{{ formatCSPerMin(game.final_stats) }}</span>
<span class="key-stat-label">CS/m</span>
</div>
<div class="key-stat">
<span class="key-stat-value">{{ formatGoldPerMin(game.final_stats) }}</span>
<span class="key-stat-label">Gold/m</span>
</div>
</div>
<!-- Items Grid -->
<div class="items-grid">
<div class="item-slot" v-for="(item, idx) in getItemsArray(game).slice(0, 6)" :key="idx">
<img
v-if="item && item.itemId"
:src="getItemImageUrl(item.itemId)"
:alt="item.name || `Item ${item.itemId}`"
class="item-image"
/>
<div v-else class="item-empty"></div>
</div>
<div class="item-slot trinket">
<img
v-if="getItemsArray(game)[6]?.itemId"
:src="getItemImageUrl(getItemsArray(game)[6]!.itemId)"
:alt="getItemsArray(game)[6]?.name || 'Trinket'"
class="item-image"
/>
<div v-else class="item-empty"></div>
</div>
</div>
</div>
<div class="stats-section no-stats" v-else>
<div class="no-stats-text">No stats</div>
</div>
</div>
</div>
</div>
<!-- Game Detail Modal -->
<div v-if="selectedGame" class="modal-overlay" @click.self="closeDetail">
<div class="modal">
<button class="modal-close" @click="closeDetail">×</button>
<div class="modal-header">
<div class="modal-champion">
<img
:src="getChampionImageUrl(selectedGame.champion)"
:alt="selectedGame.champion || 'Unknown Champion'"
class="modal-champion-image"
/>
<div class="modal-champion-info">
<h2>{{ selectedGame.champion || 'Unknown Champion' }}</h2>
<div class="modal-queue">
{{ getQueueDisplayName(selectedGame.queue_type, selectedGame.queue_id) }}
</div>
</div>
</div>
<div class="modal-result" :class="getGameResult(selectedGame).toLowerCase()">
{{ getGameResult(selectedGame) }}
</div>
</div>
<div class="modal-body">
<!-- Game Info -->
<div class="modal-section">
<h3>Game Information</h3>
<div class="detail-grid">
<div class="detail-row">
<span class="detail-label">Duration:</span>
<span class="detail-value">{{ formatDuration(selectedGame.duration_secs) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Started:</span>
<span class="detail-value">{{ formatGameStartTime(selectedGame.start_time) }}</span>
</div>
<div class="detail-row" v-if="selectedGame.game_mode">
<span class="detail-label">Game Mode:</span>
<span class="detail-value">{{ selectedGame.game_mode }}</span>
</div>
<div class="detail-row" v-if="selectedGame.map_name">
<span class="detail-label">Map:</span>
<span class="detail-value">{{ selectedGame.map_name }}</span>
</div>
<div class="detail-row" v-if="selectedGame.summoner_name">
<span class="detail-label">Summoner:</span>
<span class="detail-value">{{ selectedGame.summoner_name }}</span>
</div>
<div class="detail-row" v-if="selectedGame.team">
<span class="detail-label">Team:</span>
<span class="detail-value">{{ selectedGame.team === 100 ? 'Blue' : 'Red' }}</span>
</div>
</div>
</div>
<!-- Stats -->
<div class="modal-section" v-if="selectedGame.final_stats">
<h3>Performance</h3>
<div class="stats-highlight">
<div class="stat-highlight-item">
<span class="stat-highlight-value">{{ selectedGame.final_stats.kills }}</span>
<span class="stat-highlight-label">Kills</span>
</div>
<div class="stat-highlight-item">
<span class="stat-highlight-value">{{ selectedGame.final_stats.deaths }}</span>
<span class="stat-highlight-label">Deaths</span>
</div>
<div class="stat-highlight-item">
<span class="stat-highlight-value">{{ selectedGame.final_stats.assists }}</span>
<span class="stat-highlight-label">Assists</span>
</div>
</div>
<div class="detail-grid">
<div class="detail-row">
<span class="detail-label">KDA Ratio:</span>
<span class="detail-value">{{ calculateKDA(selectedGame.final_stats) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Creep Score:</span>
<span class="detail-value">{{ selectedGame.final_stats.creep_score }} ({{ formatCSPerMin(selectedGame.final_stats) }}/min)</span>
</div>
<div class="detail-row">
<span class="detail-label">Gold Earned:</span>
<span class="detail-value">{{ formatNumber(selectedGame.final_stats.gold_earned) }} ({{ formatGoldPerMin(selectedGame.final_stats) }}/min)</span>
</div>
<div class="detail-row">
<span class="detail-label">Damage Dealt:</span>
<span class="detail-value">{{ formatNumber(selectedGame.final_stats.damage_dealt) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Damage Taken:</span>
<span class="detail-value">{{ formatNumber(selectedGame.final_stats.damage_taken) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Vision Score:</span>
<span class="detail-value">{{ selectedGame.final_stats.vision_score.toFixed(1) }}</span>
</div>
</div>
</div>
<!-- Events Timeline -->
<div class="modal-section" v-if="selectedGame.events.length > 0">
<h3>Events ({{ selectedGame.events.length }})</h3>
<div class="events-list">
<div
v-for="(event, idx) in selectedGame.events.slice(0, 10)"
:key="idx"
class="event-item"
>
<span class="event-time">{{ formatDuration(getVideoTimestampSecs(event)) }}</span>
<span class="event-type">{{ event.event_type }}</span>
<span class="event-desc">{{ event.description }}</span>
</div>
<div v-if="selectedGame.events.length > 10" class="events-more">
+{{ selectedGame.events.length - 10 }} more events
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="closeDetail">Close</button>
<button class="btn-primary">
Open Video
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.game-history {
min-height: 100vh;
background: linear-gradient(180deg, #0a0a13 0%, #0f0f1a 100%);
color: #fff;
padding: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
font-size: 1.5rem;
margin: 0;
font-weight: 600;
color: #f0f0f0;
}
.refresh-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.refresh-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
color: #888;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #333;
border-top-color: #c8aa6e;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
text-align: center;
padding: 2rem;
color: #ff6b6b;
}
.error button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #ff6b6b;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.empty {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty h2 {
margin: 0 0 0.5rem 0;
color: #fff;
}
.empty p {
color: #888;
margin: 0;
}
/* Game List - Single Column */
.game-list {
display: flex;
flex-direction: column;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
gap: 0.5rem;
}
/* Game Card - League Client Style */
.game-card {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.game-card:hover {
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
/* Result Banner Colors */
.game-card.victory .result-banner {
background: linear-gradient(90deg, rgba(30, 144, 255, 0.3) 0%, rgba(30, 144, 255, 0.1) 100%);
border-left: 4px solid #1e90ff;
}
.game-card.victory .result-text {
color: #4da6ff;
}
.game-card.defeat .result-banner {
background: linear-gradient(90deg, rgba(255, 68, 68, 0.3) 0%, rgba(255, 68, 68, 0.1) 100%);
border-left: 4px solid #ff4444;
}
.game-card.defeat .result-text {
color: #ff6b6b;
}
.game-card.terminated .result-banner {
background: linear-gradient(90deg, rgba(156, 163, 175, 0.3) 0%, rgba(156, 163, 175, 0.1) 100%);
border-left: 4px solid #6b7280;
}
.game-card.terminated .result-text {
color: #9ca3af;
}
.result-banner {
padding: 0.35rem 0.75rem;
display: flex;
align-items: center;
}
.result-text {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Game Content */
.game-content {
display: flex;
padding: 0.5rem 0.75rem;
gap: 0.75rem;
}
/* Champion Section */
.champion-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.champion-image-wrapper {
position: relative;
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
background: rgba(0, 0, 0, 0.3);
}
.champion-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.champion-level {
position: absolute;
bottom: 1px;
right: 1px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 0.55rem;
font-weight: 600;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.summoner-spells {
display: flex;
gap: 2px;
}
.spell-slot {
width: 20px;
height: 20px;
border-radius: 3px;
overflow: hidden;
background: rgba(0, 0, 0, 0.3);
}
.spell-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Game Info Section */
.game-info-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.game-info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.game-info-left {
display: flex;
flex-direction: column;
}
.queue-type {
font-size: 0.8rem;
font-weight: 600;
color: #f0f0f0;
margin-bottom: 0.15rem;
}
.game-duration {
font-size: 0.75rem;
color: #888;
margin-bottom: 0.15rem;
}
.game-time {
font-size: 0.7rem;
color: #666;
}
.kda-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.25rem 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
}
.kda-values {
font-size: 1.1rem;
font-weight: 700;
color: #f0f0f0;
letter-spacing: 0.02em;
}
.kda-ratio {
font-size: 0.7rem;
color: #888;
}
.kda-ratio.perfect {
color: #4ade80;
}
/* Stats Section */
.stats-section {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.stats-section.no-stats {
justify-content: center;
}
.no-stats-text {
color: #666;
font-size: 0.75rem;
font-style: italic;
}
/* Key Stats Column */
.key-stats-column {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-right: 0.5rem;
}
.key-stat {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.2);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.key-stat-value {
font-size: 0.8rem;
font-weight: 600;
color: #c8aa6e;
}
.key-stat-label {
font-size: 0.6rem;
color: #888;
text-transform: uppercase;
}
/* Items Grid - 2 rows of 4 (6 items + trinket) */
.items-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2px;
}
.item-slot {
width: 24px;
height: 24px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.item-slot.trinket {
border-radius: 50%;
}
.item-placeholder {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.05);
}
.spell-placeholder {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.05);
}
.item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-empty {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.05);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #0f0f1a;
border-radius: 12px;
padding: 1.5rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #fff;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-champion {
display: flex;
align-items: center;
gap: 1rem;
}
.modal-champion-image {
width: 64px;
height: 64px;
border-radius: 8px;
object-fit: cover;
}
.modal-champion-info h2 {
margin: 0;
font-size: 1.25rem;
}
.modal-queue {
color: #888;
font-size: 0.85rem;
margin-top: 0.25rem;
}
.modal-result {
font-size: 0.9rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 4px;
}
.modal-result.victory {
background: rgba(30, 144, 255, 0.2);
color: #4da6ff;
}
.modal-result.defeat {
background: rgba(255, 68, 68, 0.2);
color: #ff6b6b;
}
.modal-result.terminated {
background: rgba(156, 163, 175, 0.2);
color: #9ca3af;
}
.modal-body {
margin-bottom: 1.5rem;
}
.modal-section {
margin-bottom: 1.5rem;
}
.modal-section h3 {
font-size: 0.9rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem 0;
}
.detail-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: #888;
}
.detail-value {
font-weight: 500;
}
.stats-highlight {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
.stat-highlight-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-highlight-value {
font-size: 1.5rem;
font-weight: 700;
color: #f0f0f0;
}
.stat-highlight-label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
}
.events-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.event-item {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
font-size: 0.85rem;
}
.event-time {
color: #c8aa6e;
font-family: monospace;
min-width: 50px;
}
.event-type {
color: #888;
min-width: 100px;
}
.event-desc {
color: #f0f0f0;
flex: 1;
}
.events-more {
text-align: center;
color: #888;
font-size: 0.85rem;
padding: 0.5rem;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn-primary,
.btn-secondary {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, #1e90ff 0%, #0066cc 100%);
color: #fff;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Responsive */
@media (max-width: 600px) {
.game-content {
flex-direction: column;
align-items: center;
text-align: center;
}
.game-info-section {
align-items: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>