tauri-app: display game history with stats
This commit is contained in:
14
tauri-app/src-tauri/Cargo.lock
generated
14
tauri-app/src-tauri/Cargo.lock
generated
@@ -715,10 +715,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
@@ -3631,7 +3631,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -3678,7 +3678,7 @@ name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"directories",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -3695,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -4219,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -5292,7 +5292,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
|
||||
@@ -24,5 +24,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dirs = "5"
|
||||
directories = "5"
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ use uuid::Uuid;
|
||||
/// A timestamped event in the timeline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimestampedEvent {
|
||||
/// Video timestamp (offset from recording start) in seconds.
|
||||
pub video_timestamp: i64,
|
||||
/// Game timestamp (in-game time) in seconds.
|
||||
pub game_timestamp: Option<i64>,
|
||||
/// Video timestamp (offset from recording start) as [seconds, nanos].
|
||||
pub video_timestamp: (i64, i32),
|
||||
/// Game timestamp (in-game time) as [seconds, nanos].
|
||||
pub game_timestamp: Option<(i64, i32)>,
|
||||
/// Real-world timestamp.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Event type name.
|
||||
@@ -19,7 +19,81 @@ pub struct TimestampedEvent {
|
||||
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.
|
||||
/// This matches the full JSON structure from record-daemon.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Timeline {
|
||||
/// Recording ID.
|
||||
@@ -32,39 +106,68 @@ pub struct Timeline {
|
||||
pub duration_secs: i64,
|
||||
/// Events in the timeline.
|
||||
pub events: Vec<TimestampedEvent>,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Champion played.
|
||||
#[serde(default)]
|
||||
pub champion: Option<String>,
|
||||
/// Game result (if available).
|
||||
pub result: Option<String>,
|
||||
/// Video file path.
|
||||
pub video_path: Option<String>,
|
||||
/// Skin name.
|
||||
#[serde(default)]
|
||||
pub skin_name: 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.
|
||||
/// Uses the same directory structure as the record-daemon.
|
||||
fn get_default_output_dir() -> Option<PathBuf> {
|
||||
dirs::video_dir()
|
||||
.map(|p| p.join("LeagueRecorder"))
|
||||
.or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder")))
|
||||
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
|
||||
.map(|dirs| dirs.data_dir().join("recordings"))
|
||||
}
|
||||
|
||||
/// Get the timeline storage directory.
|
||||
/// Uses the same directory structure as the record-daemon.
|
||||
fn get_timeline_dir() -> PathBuf {
|
||||
get_default_output_dir()
|
||||
.map(|p| p.join("timelines"))
|
||||
@@ -98,105 +201,13 @@ fn load_timelines() -> Vec<Timeline> {
|
||||
timelines
|
||||
}
|
||||
|
||||
/// Format duration as MM:SS or HH:MM:SS.
|
||||
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
|
||||
}
|
||||
|
||||
/// Get game history - returns full timeline data for each game.
|
||||
#[tauri::command]
|
||||
fn get_game_history() -> Vec<GameHistoryItem> {
|
||||
let 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()
|
||||
fn get_game_history() -> Vec<Timeline> {
|
||||
load_timelines()
|
||||
}
|
||||
|
||||
/// Get a specific timeline by recording ID.
|
||||
#[tauri::command]
|
||||
fn get_timeline(recording_id: String) -> Option<Timeline> {
|
||||
let timeline_dir = get_timeline_dir();
|
||||
@@ -211,6 +222,7 @@ fn get_timeline(recording_id: String) -> Option<Timeline> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the recordings directory path.
|
||||
#[tauri::command]
|
||||
fn get_recordings_dir() -> String {
|
||||
get_default_output_dir()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,13 @@
|
||||
|
||||
/**
|
||||
* A timestamped event in the timeline.
|
||||
* Video timestamp is serialized as [seconds, nanos] tuple from Rust.
|
||||
*/
|
||||
export interface TimestampedEvent {
|
||||
/** Video timestamp (offset from recording start) in seconds. */
|
||||
video_timestamp: number;
|
||||
/** Game timestamp (in-game time) in seconds. */
|
||||
game_timestamp: number | null;
|
||||
/** Video timestamp (offset from recording start) as [seconds, nanos]. */
|
||||
video_timestamp: [number, number];
|
||||
/** Game timestamp (in-game time) as [seconds, nanos]. */
|
||||
game_timestamp: [number, number] | null;
|
||||
/** Real-world timestamp (ISO 8601). */
|
||||
timestamp: string;
|
||||
/** Event type name. */
|
||||
@@ -18,44 +19,383 @@ export interface TimestampedEvent {
|
||||
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.
|
||||
* This is the main data structure returned by the backend.
|
||||
*/
|
||||
export interface Timeline {
|
||||
/** Recording ID (UUID). */
|
||||
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). */
|
||||
start_time: string;
|
||||
/** Game end time (ISO 8601). */
|
||||
end_time: string | null;
|
||||
/** Duration in seconds. */
|
||||
duration_secs: number;
|
||||
/** Formatted duration string (e.g., "32:15"). */
|
||||
duration_formatted: string;
|
||||
/** Number of events. */
|
||||
event_count: number;
|
||||
/** Champion played (if available). */
|
||||
/** Events in the timeline. */
|
||||
events: TimestampedEvent[];
|
||||
|
||||
// Player information
|
||||
/** Champion played. */
|
||||
champion: string | null;
|
||||
/** Game result (Victory/Defeat, if available). */
|
||||
result: string | null;
|
||||
/** Video file path. */
|
||||
video_path: string | null;
|
||||
/** Skin name. */
|
||||
skin_name: string | null;
|
||||
/** Player's summoner name. */
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user