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

File diff suppressed because it is too large Load Diff

View File

@@ -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}`;
}
}