tauri-app: display game history with stats

This commit is contained in:
2026-03-25 10:53:21 +01:00
parent f90e549b1e
commit 079d72649f
5 changed files with 1243 additions and 293 deletions

View File

@@ -715,10 +715,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "dirs" name = "directories"
version = "5.0.1" version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [ dependencies = [
"dirs-sys 0.4.1", "dirs-sys 0.4.1",
] ]
@@ -3631,7 +3631,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs 6.0.0", "dirs",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -3678,7 +3678,7 @@ name = "tauri-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "directories",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -3695,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs 6.0.0", "dirs",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@@ -4219,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs 6.0.0", "dirs",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2", "objc2",
@@ -5292,7 +5292,7 @@ dependencies = [
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs 6.0.0", "dirs",
"dom_query", "dom_query",
"dpi", "dpi",
"dunce", "dunce",

View File

@@ -24,5 +24,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
dirs = "5" directories = "5"

View File

@@ -7,10 +7,10 @@ use uuid::Uuid;
/// A timestamped event in the timeline. /// A timestamped event in the timeline.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampedEvent { pub struct TimestampedEvent {
/// Video timestamp (offset from recording start) in seconds. /// Video timestamp (offset from recording start) as [seconds, nanos].
pub video_timestamp: i64, pub video_timestamp: (i64, i32),
/// Game timestamp (in-game time) in seconds. /// Game timestamp (in-game time) as [seconds, nanos].
pub game_timestamp: Option<i64>, pub game_timestamp: Option<(i64, i32)>,
/// Real-world timestamp. /// Real-world timestamp.
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
/// Event type name. /// Event type name.
@@ -19,7 +19,81 @@ pub struct TimestampedEvent {
pub description: String, pub description: String,
} }
impl TimestampedEvent {
/// Get video timestamp in seconds.
pub fn video_timestamp_secs(&self) -> i64 {
self.video_timestamp.0
}
}
/// Final game statistics for the player.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameFinalStats {
pub kills: u32,
pub deaths: u32,
pub assists: u32,
pub creep_score: u32,
pub gold_earned: u32,
pub damage_dealt: u64,
pub damage_taken: u64,
pub vision_score: f64,
pub game_duration: f64,
}
/// Rune page configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunePage {
pub primary_style_id: u32,
pub secondary_style_id: u32,
pub selected_perks: Vec<u32>,
#[serde(default)]
pub stat_modifiers: Vec<u32>,
pub name: Option<String>,
#[serde(default)]
pub current: bool,
}
/// Summoner spell information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummonerSpells {
pub spell1_id: u32,
pub spell2_id: u32,
pub spell1_name: Option<String>,
pub spell2_name: Option<String>,
}
/// Individual item information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemInfo {
pub item_id: u32,
pub name: Option<String>,
pub slot: u32,
}
/// Item build at game end.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemBuild {
pub items: Vec<ItemInfo>,
pub trinket: Option<ItemInfo>,
}
/// Player identity information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerIdentityInfo {
pub puuid: String,
#[serde(default)]
pub summoner_name: String,
pub champion_name: Option<String>,
pub team: Option<u32>,
}
/// A timeline of events for a recording. /// A timeline of events for a recording.
/// This matches the full JSON structure from record-daemon.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Timeline { pub struct Timeline {
/// Recording ID. /// Recording ID.
@@ -32,39 +106,68 @@ pub struct Timeline {
pub duration_secs: i64, pub duration_secs: i64,
/// Events in the timeline. /// Events in the timeline.
pub events: Vec<TimestampedEvent>, pub events: Vec<TimestampedEvent>,
} /// Champion played.
#[serde(default)]
/// Game history item for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameHistoryItem {
/// Recording ID.
pub id: String,
/// Game start time.
pub start_time: DateTime<Utc>,
/// Game end time.
pub end_time: Option<DateTime<Utc>>,
/// Duration in seconds.
pub duration_secs: i64,
/// Formatted duration string (e.g., "32:15").
pub duration_formatted: String,
/// Number of events.
pub event_count: usize,
/// Champion played (if available).
pub champion: Option<String>, pub champion: Option<String>,
/// Game result (if available). /// Skin name.
pub result: Option<String>, #[serde(default)]
/// Video file path. pub skin_name: Option<String>,
pub video_path: Option<String>, /// Queue type.
#[serde(default)]
pub queue_type: Option<String>,
/// Queue ID.
#[serde(default)]
pub queue_id: Option<u32>,
/// Game mode.
#[serde(default)]
pub game_mode: Option<String>,
/// Map name.
#[serde(default)]
pub map_name: Option<String>,
/// Summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Player's PUUID.
#[serde(default)]
pub puuid: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
pub team: Option<u32>,
/// Whether the game was won.
#[serde(default)]
pub victory: Option<bool>,
/// Final player stats.
#[serde(default)]
pub final_stats: Option<GameFinalStats>,
/// Game ID.
#[serde(default)]
pub game_id: Option<u64>,
/// Match ID.
#[serde(default)]
pub match_id: Option<String>,
/// Rune page at game start.
#[serde(default)]
pub runes: Option<RunePage>,
/// Summoner spells.
#[serde(default)]
pub summoner_spells: Option<SummonerSpells>,
/// Final item build at game end.
#[serde(default)]
pub final_items: Option<ItemBuild>,
/// All players in the game.
#[serde(default)]
pub all_players: Vec<PlayerIdentityInfo>,
} }
/// Get the default output directory for recordings. /// Get the default output directory for recordings.
/// Uses the same directory structure as the record-daemon.
fn get_default_output_dir() -> Option<PathBuf> { fn get_default_output_dir() -> Option<PathBuf> {
dirs::video_dir() directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
.map(|p| p.join("LeagueRecorder")) .map(|dirs| dirs.data_dir().join("recordings"))
.or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder")))
} }
/// Get the timeline storage directory. /// Get the timeline storage directory.
/// Uses the same directory structure as the record-daemon.
fn get_timeline_dir() -> PathBuf { fn get_timeline_dir() -> PathBuf {
get_default_output_dir() get_default_output_dir()
.map(|p| p.join("timelines")) .map(|p| p.join("timelines"))
@@ -98,105 +201,13 @@ fn load_timelines() -> Vec<Timeline> {
timelines timelines
} }
/// Format duration as MM:SS or HH:MM:SS. /// Get game history - returns full timeline data for each game.
fn format_duration(secs: i64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{:02}:{:02}", minutes, seconds)
}
}
/// Extract game result from events.
fn extract_game_result(timeline: &Timeline) -> Option<String> {
for event in &timeline.events {
if event.event_type == "GameEnd" {
// Check description for win/loss
if event.description.to_lowercase().contains("win") {
return Some("Victory".to_string());
} else if event.description.to_lowercase().contains("loss")
|| event.description.to_lowercase().contains("defeat")
{
return Some("Defeat".to_string());
}
}
}
None
}
/// Extract champion from events.
fn extract_champion(timeline: &Timeline) -> Option<String> {
for event in &timeline.events {
if event.event_type == "GameStart" {
// Try to extract champion from description
let desc = &event.description;
if desc.contains("Playing as ") {
return Some(desc.replace("Playing as ", "").trim().to_string());
}
}
}
None
}
/// Extract video file path from timeline.
fn extract_video_path(timeline: &Timeline) -> Option<String> {
// Look for video file in the recordings directory
let output_dir = get_default_output_dir()?;
let video_dir = output_dir.join("videos");
if video_dir.exists() {
if let Ok(entries) = fs::read_dir(&video_dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_stem = path.file_stem()?.to_string_lossy().to_string();
if file_stem.contains(&timeline.recording_id.to_string()) {
return Some(path.to_string_lossy().to_string());
}
}
}
}
// Also check for files named with the recording ID
let recording_id = timeline.recording_id.to_string();
if let Ok(entries) = fs::read_dir(&output_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let file_stem = path.file_stem()?.to_string_lossy().to_string();
if file_stem.contains(&recording_id) {
return Some(path.to_string_lossy().to_string());
}
}
}
}
None
}
#[tauri::command] #[tauri::command]
fn get_game_history() -> Vec<GameHistoryItem> { fn get_game_history() -> Vec<Timeline> {
let timelines = load_timelines(); load_timelines()
timelines
.into_iter()
.map(|timeline| GameHistoryItem {
id: timeline.recording_id.to_string(),
start_time: timeline.start_time,
end_time: timeline.end_time,
duration_secs: timeline.duration_secs,
duration_formatted: format_duration(timeline.duration_secs),
event_count: timeline.events.len(),
champion: extract_champion(&timeline),
result: extract_game_result(&timeline),
video_path: extract_video_path(&timeline),
})
.collect()
} }
/// Get a specific timeline by recording ID.
#[tauri::command] #[tauri::command]
fn get_timeline(recording_id: String) -> Option<Timeline> { fn get_timeline(recording_id: String) -> Option<Timeline> {
let timeline_dir = get_timeline_dir(); let timeline_dir = get_timeline_dir();
@@ -211,6 +222,7 @@ fn get_timeline(recording_id: String) -> Option<Timeline> {
None None
} }
/// Get the recordings directory path.
#[tauri::command] #[tauri::command]
fn get_recordings_dir() -> String { fn get_recordings_dir() -> String {
get_default_output_dir() get_default_output_dir()

View File

@@ -1,8 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { GameHistoryItem } from "../types/timeline"; import type { GameHistoryItem, GameResult, TimestampedEvent, ItemInfo } from "../types/timeline";
import { formatRelativeTime } 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 games = ref<GameHistoryItem[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -30,6 +49,24 @@ function closeDetail() {
selectedGame.value = null; selectedGame.value = null;
} }
// Helper to get items array for display (6 slots + trinket)
function getItemsArray(game: GameHistoryItem): (ItemInfo | null)[] {
const result: (ItemInfo | null)[] = [null, null, null, null, null, null, null];
if (game.final_items) {
// Fill main items (slots 0-5)
for (const item of game.final_items.items) {
if (item.slot >= 0 && item.slot <= 5) {
result[item.slot] = item;
}
}
// Fill trinket (slot 6)
if (game.final_items.trinket) {
result[6] = game.final_items.trinket;
}
}
return result;
}
onMounted(() => { onMounted(() => {
loadGameHistory(); loadGameHistory();
}); });
@@ -38,14 +75,17 @@ onMounted(() => {
<template> <template>
<div class="game-history"> <div class="game-history">
<header class="header"> <header class="header">
<h1>League Recorder</h1> <h1>Match History</h1>
<p class="subtitle">Game History</p> <button class="refresh-btn" @click="loadGameHistory" :disabled="loading">
<span v-if="loading"></span>
<span v-else></span>
</button>
</header> </header>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="loading"> <div v-if="loading" class="loading">
<div class="spinner"></div> <div class="spinner"></div>
<p>Loading game history...</p> <p>Loading match history...</p>
</div> </div>
<!-- Error State --> <!-- Error State -->
@@ -58,38 +98,137 @@ onMounted(() => {
<div v-else-if="games.length === 0" class="empty"> <div v-else-if="games.length === 0" class="empty">
<div class="empty-icon">🎮</div> <div class="empty-icon">🎮</div>
<h2>No Games Recorded</h2> <h2>No Games Recorded</h2>
<p>Start playing to see your game history here.</p> <p>Start playing to see your match history here.</p>
</div> </div>
<!-- Game List --> <!-- Game List - Single Column -->
<div v-else class="game-list"> <div v-else class="game-list">
<div <div
v-for="game in games" v-for="game in games"
:key="game.id" :key="game.recording_id"
class="game-card" class="game-card"
:class="{ victory: game.result === 'Victory', defeat: game.result === 'Defeat' }" :class="{
victory: getGameResult(game) === 'Victory',
defeat: getGameResult(game) === 'Defeat',
terminated: getGameResult(game) === 'Terminated'
}"
@click="selectGame(game)" @click="selectGame(game)"
> >
<div class="game-result-badge" v-if="game.result"> <!-- Result Banner -->
{{ game.result }} <div class="result-banner">
<span class="result-text">{{ getGameResult(game) }}</span>
</div> </div>
<div class="game-main"> <!-- Main Content -->
<div class="game-champion" v-if="game.champion"> <div class="game-content">
{{ game.champion }} <!-- 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>
<div class="game-champion unknown" v-else>
Unknown Champion
</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"> <div class="game-duration">
{{ game.duration_formatted }} {{ formatDuration(game.duration_secs) }}
</div>
<div class="game-time">
{{ formatRelativeTime(game.start_time) }}
</div> </div>
</div> </div>
<div class="game-meta"> <!-- Right: KDA Score -->
<span class="game-time">{{ formatRelativeTime(game.start_time) }}</span> <div class="kda-section" v-if="game.final_stats">
<span class="game-events">{{ game.event_count }} events</span> <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] && 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> </div>
</div> </div>
@@ -100,42 +239,124 @@ onMounted(() => {
<button class="modal-close" @click="closeDetail">×</button> <button class="modal-close" @click="closeDetail">×</button>
<div class="modal-header"> <div class="modal-header">
<h2>Game Details</h2> <div class="modal-champion">
<div class="modal-result" :class="selectedGame.result?.toLowerCase()"> <img
{{ selectedGame.result || 'Unknown Result' }} :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> </div>
<div class="modal-body"> <div class="modal-body">
<div class="detail-row"> <!-- Game Info -->
<span class="detail-label">Champion:</span> <div class="modal-section">
<span class="detail-value">{{ selectedGame.champion || 'Unknown' }}</span> <h3>Game Information</h3>
</div> <div class="detail-grid">
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">Duration:</span> <span class="detail-label">Duration:</span>
<span class="detail-value">{{ selectedGame.duration_formatted }}</span> <span class="detail-value">{{ formatDuration(selectedGame.duration_secs) }}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">Started:</span> <span class="detail-label">Started:</span>
<span class="detail-value">{{ new Date(selectedGame.start_time).toLocaleString() }}</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> </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"> <div class="detail-row">
<span class="detail-label">Events:</span> <span class="detail-label">KDA Ratio:</span>
<span class="detail-value">{{ selectedGame.event_count }}</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> </div>
<div class="detail-row" v-if="selectedGame.video_path"> <!-- Events Timeline -->
<span class="detail-label">Video:</span> <div class="modal-section" v-if="selectedGame.events.length > 0">
<span class="detail-value video-path">{{ selectedGame.video_path }}</span> <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> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-secondary" @click="closeDetail">Close</button> <button class="btn-secondary" @click="closeDetail">Close</button>
<button class="btn-primary" v-if="selectedGame.video_path"> <button class="btn-primary">
Open Video Open Video
</button> </button>
</div> </div>
@@ -147,28 +368,52 @@ onMounted(() => {
<style scoped> <style scoped>
.game-history { .game-history {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); background: linear-gradient(180deg, #0a0a13 0%, #0f0f1a 100%);
color: #fff; color: #fff;
padding: 2rem; padding: 0;
} }
.header { .header {
text-align: center; display: flex;
margin-bottom: 2rem; 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 { .header h1 {
font-size: 2.5rem; font-size: 1.5rem;
margin: 0; margin: 0;
background: linear-gradient(90deg, #00d4ff, #7b2cbf); font-weight: 600;
-webkit-background-clip: text; color: #f0f0f0;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.subtitle { .refresh-btn {
color: #888; background: rgba(255, 255, 255, 0.1);
margin: 0.5rem 0 0 0; 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 { .loading {
@@ -184,7 +429,7 @@ onMounted(() => {
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 3px solid #333; border: 3px solid #333;
border-top-color: #00d4ff; border-top-color: #c8aa6e;
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@@ -231,91 +476,296 @@ onMounted(() => {
margin: 0; margin: 0;
} }
/* Game List - Single Column */
.game-list { .game-list {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); flex-direction: column;
gap: 1rem; max-width: 900px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem;
gap: 0.5rem;
} }
/* Game Card - League Client Style */
.game-card { .game-card {
background: rgba(255, 255, 255, 0.05); display: flex;
border-radius: 12px; flex-direction: column;
padding: 1.25rem; background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.05);
} }
.game-card:hover { .game-card:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06);
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
} }
.game-card.victory { /* Result Banner Colors */
border-left: 4px solid #4ade80; .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.defeat { .game-card.victory .result-text {
border-left: 4px solid #f87171; color: #4da6ff;
} }
.game-result-badge { .game-card.defeat .result-banner {
position: absolute; background: linear-gradient(90deg, rgba(255, 68, 68, 0.3) 0%, rgba(255, 68, 68, 0.1) 100%);
top: 0.75rem; border-left: 4px solid #ff4444;
right: 0.75rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
} }
.game-card.victory .game-result-badge { .game-card.defeat .result-text {
background: rgba(74, 222, 128, 0.2); color: #ff6b6b;
color: #4ade80;
} }
.game-card.defeat .game-result-badge { .game-card.terminated .result-banner {
background: rgba(248, 113, 113, 0.2); background: linear-gradient(90deg, rgba(156, 163, 175, 0.3) 0%, rgba(156, 163, 175, 0.1) 100%);
color: #f87171; border-left: 4px solid #6b7280;
} }
.game-main { .game-card.terminated .result-text {
color: #9ca3af;
}
.result-banner {
padding: 0.35rem 0.75rem;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.75rem;
} }
.game-champion { .result-text {
font-size: 1.25rem; 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; font-weight: 600;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
} }
.game-champion.unknown { .summoner-spells {
color: #888; display: flex;
font-style: italic; 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 { .game-duration {
font-size: 1.1rem; font-size: 0.75rem;
font-family: monospace; color: #888;
color: #00d4ff; margin-bottom: 0.15rem;
} }
.game-meta { .game-time {
font-size: 0.7rem;
color: #666;
}
.kda-section {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
font-size: 0.85rem; 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; 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 Styles */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -323,7 +773,7 @@ onMounted(() => {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.8);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -332,11 +782,13 @@ onMounted(() => {
} }
.modal { .modal {
background: #1a1a2e; background: #0f0f1a;
border-radius: 16px; border-radius: 12px;
padding: 2rem; padding: 1.5rem;
max-width: 500px; max-width: 600px;
width: 100%; width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative; position: relative;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
@@ -363,11 +815,32 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
} }
.modal-header h2 { .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; margin: 0;
font-size: 1.5rem; font-size: 1.25rem;
}
.modal-queue {
color: #888;
font-size: 0.85rem;
margin-top: 0.25rem;
} }
.modal-result { .modal-result {
@@ -378,23 +851,46 @@ onMounted(() => {
} }
.modal-result.victory { .modal-result.victory {
background: rgba(74, 222, 128, 0.2); background: rgba(30, 144, 255, 0.2);
color: #4ade80; color: #4da6ff;
} }
.modal-result.defeat { .modal-result.defeat {
background: rgba(248, 113, 113, 0.2); background: rgba(255, 68, 68, 0.2);
color: #f87171; color: #ff6b6b;
}
.modal-result.terminated {
background: rgba(156, 163, 175, 0.2);
color: #9ca3af;
} }
.modal-body { .modal-body {
margin-bottom: 1.5rem; 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 { .detail-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 0; padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
@@ -410,13 +906,70 @@ onMounted(() => {
font-weight: 500; font-weight: 500;
} }
.video-path { .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; font-size: 0.85rem;
color: #00d4ff; }
max-width: 250px;
overflow: hidden; .event-time {
text-overflow: ellipsis; color: #c8aa6e;
white-space: nowrap; 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 { .modal-actions {
@@ -436,7 +989,7 @@ onMounted(() => {
} }
.btn-primary { .btn-primary {
background: linear-gradient(90deg, #00d4ff, #7b2cbf); background: linear-gradient(135deg, #1e90ff 0%, #0066cc 100%);
color: #fff; color: #fff;
} }
@@ -453,4 +1006,21 @@ onMounted(() => {
.btn-secondary:hover { .btn-secondary:hover {
background: rgba(255, 255, 255, 0.15); 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> </style>

View File

@@ -4,12 +4,13 @@
/** /**
* A timestamped event in the timeline. * A timestamped event in the timeline.
* Video timestamp is serialized as [seconds, nanos] tuple from Rust.
*/ */
export interface TimestampedEvent { export interface TimestampedEvent {
/** Video timestamp (offset from recording start) in seconds. */ /** Video timestamp (offset from recording start) as [seconds, nanos]. */
video_timestamp: number; video_timestamp: [number, number];
/** Game timestamp (in-game time) in seconds. */ /** Game timestamp (in-game time) as [seconds, nanos]. */
game_timestamp: number | null; game_timestamp: [number, number] | null;
/** Real-world timestamp (ISO 8601). */ /** Real-world timestamp (ISO 8601). */
timestamp: string; timestamp: string;
/** Event type name. */ /** Event type name. */
@@ -18,44 +19,383 @@ export interface TimestampedEvent {
description: string; description: string;
} }
/**
* Final game statistics for the player.
*/
export interface GameFinalStats {
/** Kills. */
kills: number;
/** Deaths. */
deaths: number;
/** Assists. */
assists: number;
/** Creep score. */
creep_score: number;
/** Gold earned. */
gold_earned: number;
/** Damage dealt. */
damage_dealt: number;
/** Damage taken. */
damage_taken: number;
/** Vision score. */
vision_score: number;
/** Game duration in seconds. */
game_duration: number;
}
/**
* Rune page configuration.
*/
export interface RunePage {
/** Primary rune style (keystone tree). */
primary_style_id: number;
/** Secondary rune style. */
secondary_style_id: number;
/** Selected rune perk IDs. */
selected_perks: number[];
/** Stat modifier IDs. */
stat_modifiers: number[];
/** Rune page name. */
name: string | null;
/** Whether this is the current page. */
current: boolean;
}
/**
* Summoner spell information.
*/
export interface SummonerSpells {
/** First summoner spell ID. */
spell1Id: number;
/** Second summoner spell ID. */
spell2Id: number;
/** First summoner spell name. */
spell1Name: string | null;
/** Second summoner spell name. */
spell2Name: string | null;
}
/**
* Individual item information.
*/
export interface ItemInfo {
/** Item ID. */
itemId: number;
/** Item name. */
name: string | null;
/** Slot index (0-5 for main items, 6 for trinket). */
slot: number;
}
/**
* Item build at game end.
*/
export interface ItemBuild {
/** Final items (up to 6 item slots). */
items: ItemInfo[];
/** Trinket item. */
trinket: ItemInfo | null;
}
/**
* Player identity information for puuid to summoner name mapping.
*/
export interface PlayerIdentityInfo {
/** Player's PUUID. */
puuid: string;
/** Player's summoner name. */
summoner_name: string;
/** Player's champion name. */
champion_name: string | null;
/** Player's team (100 or 200). */
team: number | null;
}
/**
* Game result type.
*/
export type GameResult = 'Victory' | 'Defeat' | 'Terminated';
/** /**
* A timeline of events for a recording. * A timeline of events for a recording.
* This is the main data structure returned by the backend.
*/ */
export interface Timeline { export interface Timeline {
/** Recording ID (UUID). */ /** Recording ID (UUID). */
recording_id: string; recording_id: string;
/** Recording start time (ISO 8601). */
start_time: string;
/** Recording end time (ISO 8601). */
end_time: string | null;
/** Total duration in seconds. */
duration_secs: number;
/** Events in the timeline. */
events: TimestampedEvent[];
}
/**
* Game history item for display in the UI.
*/
export interface GameHistoryItem {
/** Recording ID (UUID). */
id: string;
/** Game start time (ISO 8601). */ /** Game start time (ISO 8601). */
start_time: string; start_time: string;
/** Game end time (ISO 8601). */ /** Game end time (ISO 8601). */
end_time: string | null; end_time: string | null;
/** Duration in seconds. */ /** Duration in seconds. */
duration_secs: number; duration_secs: number;
/** Formatted duration string (e.g., "32:15"). */ /** Events in the timeline. */
duration_formatted: string; events: TimestampedEvent[];
/** Number of events. */
event_count: number; // Player information
/** Champion played (if available). */ /** Champion played. */
champion: string | null; champion: string | null;
/** Game result (Victory/Defeat, if available). */ /** Skin name. */
result: string | null; skin_name: string | null;
/** Video file path. */ /** Player's summoner name. */
video_path: string | null; summoner_name: string | null;
/** Player's PUUID. */
puuid: string | null;
/** Team (100 = blue, 200 = red). */
team: number | null;
// Game information
/** Queue type (ranked, normal, aram, etc.). */
queue_type: string | null;
/** Queue ID. */
queue_id: number | null;
/** Game mode. */
game_mode: string | null;
/** Map name. */
map_name: string | null;
/** Game ID. */
game_id: number | null;
/** Match ID. */
match_id: string | null;
// Result
/** Whether the game was won. */
victory: boolean | null;
/** Final player stats. */
final_stats: GameFinalStats | null;
// Player metadata (runes, summoner spells, items)
/** Rune page at game start. */
runes: RunePage | null;
/** Summoner spells. */
summoner_spells: SummonerSpells | null;
/** Final item build at game end. */
final_items: ItemBuild | null;
// All players in the game (puuid to summoner name mapping)
/** All players in the game. */
all_players: PlayerIdentityInfo[];
}
/**
* Game history item for display in the UI.
* Alias for Timeline since the backend returns full timeline data.
*/
export type GameHistoryItem = Timeline;
/**
* Get the game result as a display string.
*/
export function getGameResult(game: GameHistoryItem): GameResult {
if (game.victory === true) return 'Victory';
if (game.victory === false) return 'Defeat';
return 'Terminated';
}
/**
* Get the result color class.
*/
export function getResultColor(result: GameResult): string {
switch (result) {
case 'Victory': return '#4ade80'; // green
case 'Defeat': return '#f87171'; // red
case 'Terminated': return '#9ca3af'; // gray
}
}
/**
* Get the result background color (with alpha).
*/
export function getResultBgColor(result: GameResult): string {
switch (result) {
case 'Victory': return 'rgba(74, 222, 128, 0.15)';
case 'Defeat': return 'rgba(248, 113, 113, 0.15)';
case 'Terminated': return 'rgba(156, 163, 175, 0.15)';
}
}
/**
* Format duration in MM:SS format.
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Format KDA as "K/D/A" string.
*/
export function formatKDA(stats: GameFinalStats | null): string {
if (!stats) return '0/0/0';
return `${stats.kills}/${stats.deaths}/${stats.assists}`;
}
/**
* Calculate KDA ratio.
*/
export function calculateKDA(stats: GameFinalStats | null): string {
if (!stats) return '0.0';
const kda = stats.deaths === 0
? stats.kills + stats.assists
: (stats.kills + stats.assists) / stats.deaths;
return kda.toFixed(1);
}
/**
* Format CS per minute.
*/
export function formatCSPerMin(stats: GameFinalStats | null): string {
if (!stats || stats.game_duration === 0) return '0.0';
const csPerMin = stats.creep_score / (stats.game_duration / 60);
return csPerMin.toFixed(1);
}
/**
* Format gold per minute.
*/
export function formatGoldPerMin(stats: GameFinalStats | null): string {
if (!stats || stats.game_duration === 0) return '0';
const goldPerMin = stats.gold_earned / (stats.game_duration / 60);
return Math.round(goldPerMin).toLocaleString();
}
/**
* Format large numbers (e.g., damage, gold).
*/
export function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
/**
* Get champion image URL from Data Dragon.
*/
export function getChampionImageUrl(championName: string | null, size: 'square' | 'loading' = 'square'): string {
if (!championName) {
// Return placeholder for unknown champion
return 'https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/Aatrox.png';
}
// Data Dragon uses specific champion name formatting
const formattedName = formatChampionName(championName);
if (size === 'loading') {
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}_0.jpg`;
}
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}.png`;
}
/**
* Format champion name for Data Dragon URL.
*/
function formatChampionName(name: string): string {
// Handle special cases
const specialCases: Record<string, string> = {
'Wukong': 'MonkeyKing',
'Nunu & Willump': 'Nunu',
'Renata Glasc': 'Renata',
'K\'Tanthi Aatrox': 'Aatrox',
'K\'Tanthi Varus': 'Varus',
};
if (specialCases[name]) {
return specialCases[name];
}
// Remove spaces and special characters, capitalize first letter of each word
return name
.split(/[\s&]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('')
.replace(/[^a-zA-Z]/g, '');
}
/**
* Get summoner spell image URL from Data Dragon.
*/
export function getSummonerSpellUrl(spellId: number): string {
// Map common spell IDs to names
// Reference: https://github.com/RiotGames/developer-relations/issues/478
const spellNames: Record<number, string> = {
1: 'SummonerBoost', // Cleanse
3: 'SummonerExhaust', // Exhaust
4: 'SummonerFlash', // Flash
6: 'SummonerHaste', // Ghost (ID 6 is Ghost, not Heal!)
7: 'SummonerHeal', // Heal
11: 'SummonerSmite', // Smite
12: 'SummonerTeleport', // Teleport
13: 'SummonerClarity', // Clarity
14: 'SummonerIgnite', // Ignite
21: 'SummonerBarrier', // Barrier
32: 'SummonerMark', // Mark (ARAM snowball)
39: 'SummonerSnowURFSnowball_Mark', // Mark (URF)
54: 'SummonerPod', // Porcelain
55: 'SummonerPoroRecall', // Poro Recall
56: 'SummonerPoroThrow', // Poro Throw
};
const spellName = spellNames[spellId] || 'SummonerFlash';
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/spell/${spellName}.png`;
}
/**
* Get item image URL from Data Dragon.
*/
export function getItemImageUrl(itemId: number): string {
if (itemId === 0 || itemId === null) {
return ''; // Empty item slot
}
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/item/${itemId}.png`;
}
/**
* Get queue type display name.
*/
export function getQueueDisplayName(queueType: string | null, queueId: number | null): string {
if (queueType) {
// Clean up queue type names
if (queueType.includes('Practice Tool')) return 'Practice Tool';
if (queueType.includes('Ranked Solo')) return 'Ranked Solo';
if (queueType.includes('Ranked Flex')) return 'Ranked Flex';
if (queueType.includes('ARAM')) return 'ARAM';
if (queueType.includes('Normal')) return 'Normal';
if (queueType.includes('Co-op')) return 'Co-op vs AI';
return queueType;
}
// Fallback to queue ID mapping
const queueNames: Record<number, string> = {
400: 'Normal Draft',
420: 'Ranked Solo',
430: 'Normal Blind',
440: 'Ranked Flex',
450: 'ARAM',
700: 'Clash',
800: 'Co-op vs AI',
830: 'Co-op vs AI',
840: 'Co-op vs AI',
850: 'Co-op vs AI',
900: 'URF',
1020: 'One for All',
1300: 'Nexus Blitz',
1400: 'Ultimate Spellbook',
1900: 'URF',
2000: 'Tutorial',
2010: 'Tutorial',
2020: 'Tutorial',
3140: 'Practice Tool',
};
if (queueId && queueNames[queueId]) {
return queueNames[queueId];
}
return 'Custom Game';
} }
/** /**
@@ -152,3 +492,31 @@ export function formatRelativeTime(isoString: string): string {
return "Just now"; return "Just now";
} }
} }
/**
* Format game start time for display (e.g., "Today at 3:45 PM").
*/
export function formatGameStartTime(isoString: string): string {
const date = new Date(isoString);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const timeStr = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
if (isToday) {
return `Today at ${timeStr}`;
} else if (isYesterday) {
return `Yesterday at ${timeStr}`;
} else {
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
}) + ` at ${timeStr}`;
}
}