tauri-app: display game history with stats
This commit is contained in:
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