Files
snatchgame/client/src/views/Leaderboard.vue
2025-08-28 03:21:49 -06:00

1214 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="leaderboard light">
<div class="header glass light">
<div class="header-left">
<button class="btn-back" @click="goHome" title="Volver al inicio">
<span class="label">Inicio</span>
</button>
<h1><span class="emoji">📈</span> Leaderboard</h1>
</div>
<div class="actions">
<button class="btn-collapse" @click="filtersCollapsed = !filtersCollapsed" :title="filtersCollapsed ? 'Mostrar filtros' : 'Ocultar filtros'">
<span class="collapse-icon" :class="{ rotated: filtersCollapsed }"></span>
<span class="collapse-text">{{ filtersCollapsed ? 'Mostrar filtros' : 'Ocultar filtros' }}</span>
</button>
<button class="btn" @click="downloadJSON" :disabled="loading" title="Descargar datos actuales como JSON">
📊 <span class="label">JSON</span>
</button>
</div>
</div>
<div class="filters-section" :class="{ collapsed: filtersCollapsed }">
<Transition name="filters-slide">
<div v-if="!filtersCollapsed" class="filters-content">
<DataSourceSelector v-model="eventFilters.dataSource.value" />
<EventFilters
:round-filter="eventFilters.roundFilter.value"
:game-filter="eventFilters.gameFilter.value"
:has-active-filters="eventFilters.hasActiveFilters.value"
:filter-summary="eventFilters.filterSummary.value"
@update:round-filter="eventFilters.roundFilter.value = $event"
@update:game-filter="eventFilters.gameFilter.value = $event"
@reset-filters="eventFilters.resetFilters"
/>
</div>
</Transition>
</div>
<Transition name="filters-slide">
<div v-if="!filtersCollapsed" class="controls glass light">
<div class="legend">
<span class="key global"></span> Global
<span class="sep">·</span>
<span class="key player" v-if="selectedUuid"></span> Jugador
<span class="sep" v-if="selectedRoomId">·</span>
<span class="key room" v-if="selectedRoomId"></span> Sala
</div>
<div class="player-chips">
<div class="search-controls">
<input class="search" v-model="search" placeholder="Buscar jugador…" />
<div class="pagination compact" v-if="pageCount > 1">
<button class="pg-btn compact" @click="prevPage" :disabled="page <= 1"></button>
<span class="pg-ind">{{ page }}/{{ pageCount }}</span>
<button class="pg-btn compact" @click="nextPage" :disabled="page >= pageCount"></button>
</div>
</div>
<div class="chips">
<button
v-for="p in playersPage"
:key="p.uuid"
class="chip"
:class="{ active: p.uuid === selectedUuid }"
@click="selectPlayer(p.uuid)"
:title="p.uuid"
:style="{ '--primary': p.color || '#667eea' } as any"
>
<span class="avatar">{{ initials(p.name) }}</span>
<span class="label">{{ p.name || 'Jugador' }}</span>
</button>
<button v-if="selectedUuid" class="chip clear" @click="clearPlayer">Quitar selección</button>
</div>
</div>
<!-- Room Filter Section -->
<div class="room-chips">
<div class="search-controls">
<input class="search" v-model="roomSearch" placeholder="Buscar sala…" />
<div class="pagination compact" v-if="roomPageCount > 1">
<button class="pg-btn compact" @click="prevRoomPage" :disabled="roomPage <= 1"></button>
<span class="pg-ind">{{ roomPage }}/{{ roomPageCount }}</span>
<button class="pg-btn compact" @click="nextRoomPage" :disabled="roomPage >= roomPageCount"></button>
</div>
</div>
<div class="chips">
<button
v-for="r in roomsPage"
:key="r.roomId"
class="chip room-chip"
:class="{ active: r.roomId === selectedRoomId }"
@click="selectRoom(r.roomId)"
:title="`Sala: ${r.roomId} (${r.playerCount || 0} jugadores)`"
>
<span class="avatar">🏠</span>
<span class="label">{{ r.name }}</span>
<span class="count" v-if="r.playerCount">{{ r.playerCount }}</span>
</button>
<button v-if="selectedRoomId" class="chip clear" @click="clearRoom">Quitar selección</button>
</div>
</div>
</div>
</Transition>
<EventChart
:event-types="ALL_CHART_TYPES"
:event-styles="EVENT_STYLES"
:global-event-counts="combinedGlobalCounts"
:player-event-counts="combinedPlayerCounts"
:selected-player-uuid="selectedUuid"
:player-bar-gradient="playerBarGradient"
view-mode="ratio"
:loading="loading"
:filters-collapsed="filtersCollapsed"
:active-filters="activeFilters"
:group-totals="{
offers: offersTotal,
responses: responsesTotal,
force: forceTotal,
shame: shameTotal,
report: reportTotal,
averageScore: averageScoreTotal,
totalPlayers: totalPlayersCount
}"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import EventChart from '../components/EventChart.vue';
import EventFilters from '../components/EventFilters.vue';
import DataSourceSelector from '../components/DataSourceSelector.vue';
import { useEventFilters } from '../composables/useEventFilters';
interface RoomInfo { roomId: string; metadata?: any; }
interface RoomState { players?: any[]; systemMessages?: { kind: string }[] }
const router = useRouter();
const loading = ref(false);
const eventFilters = useEventFilters();
const filtersCollapsed = ref(false);
const EVENTS = [
'p1_propose', 'p1_no_offer',
'p2_snatch', 'p2_accept', 'p2_force', 'p2_no_force', 'p2_reject',
'p1_shame', 'p1_no_shame', 'p1_report', 'p1_no_report'
];
// New metric types for additional charts
const METRICS = [
'players_seated', 'score_p1', 'score_p2', 'players_with_shame', 'players_without_shame'
];
const ALL_CHART_TYPES = [...EVENTS, ...METRICS];
// Event and metric styles matching OfferActions and OfferControls components
const EVENT_STYLES: Record<string, { icon: string; color: string; gradient: string }> = {
// Event types
'p1_propose': { icon: '✨', color: '#667eea', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
'p1_no_offer': { icon: '❌', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
'p2_accept': { icon: '✓', color: '#10b981', gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
'p2_reject': { icon: '✕', color: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' },
'p2_snatch': { icon: '👹', color: '#ef4444', gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' },
'p2_force': { icon: '⚡', color: '#667eea', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
'p2_no_force': { icon: '🚫', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
'p1_shame': { icon: '😶', color: '#fbbf24', gradient: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)' },
'p1_no_shame': { icon: '🙂', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
'p1_report': { icon: '⚖️', color: '#8b5cf6', gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' },
'p1_no_report': { icon: '🤝', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
// Metric types
'players_seated': { icon: '👥', color: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' },
'score_p1': { icon: '🦃', color: '#16a34a', gradient: 'linear-gradient(135deg, #16a34a 0%, #15803d 100%)' },
'score_p2': { icon: '🌽', color: '#d97706', gradient: 'linear-gradient(135deg, #d97706 0%, #b45309 100%)' },
'players_with_shame': { icon: '😶', color: '#dc2626', gradient: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)' },
'players_without_shame': { icon: '👥', color: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' }
};
// Additional metrics computation
const additionalMetrics = ref<Record<string, number>>({
players_seated: 0,
score_p1: 0,
score_p2: 0,
players_with_shame: 0,
players_without_shame: 0
});
const selectedPlayerMetrics = ref<Record<string, number>>({
players_seated: 0,
score_p1: 0,
score_p2: 0,
players_with_shame: 0,
players_without_shame: 0
});
// Store room score history from players
const allPlayersWithScores = ref<any[]>([]);
// Function to check if a score passes the current filters
function scorePassesFilters(score: any, roomId: string) {
// Room filter
if (eventFilters.roomFilter.value !== 'all' && roomId !== eventFilters.roomFilter.value) {
return false;
}
// Round filter
if (eventFilters.roundFilter.value !== 'all' && score.round !== eventFilters.roundFilter.value) {
return false;
}
// Game variant filter
if (eventFilters.gameFilter.value !== 'all' && score.variant !== eventFilters.gameFilter.value) {
return false;
}
return true;
}
// Function to compute additional metrics from players' score history
function computeMetricsFromScores() {
if (!allPlayersWithScores.value.length) return;
let totalP1Scores = 0;
let totalP2Scores = 0;
let p1Count = 0;
let p2Count = 0;
let playersWithShame = 0;
let totalPlayersWithNames = 0;
// Get score data from players with room score history
allPlayersWithScores.value.forEach((player: any) => {
if (player.name) {
totalPlayersWithNames++;
// Count players with shame tokens
if (player.shameTokens && player.shameTokens > 0) {
playersWithShame++;
}
// Extract scores from roomScoreHistory if available
if (player.roomScoreHistory) {
player.roomScoreHistory.forEach((roomScore: any) => {
roomScore.scores.forEach((score: any) => {
// Apply filters to scores
if (!scorePassesFilters(score, roomScore.roomId)) {
return;
}
if (score.role === 'P1') {
totalP1Scores += score.score;
p1Count++;
} else if (score.role === 'P2') {
totalP2Scores += score.score;
p2Count++;
}
});
});
}
}
});
const avgP1Score = p1Count > 0 ? totalP1Scores / p1Count : 0;
const avgP2Score = p2Count > 0 ? totalP2Scores / p2Count : 0;
additionalMetrics.value = {
players_seated: totalPlayersWithNames,
score_p1: Math.round(avgP1Score * 10) / 10, // Round to 1 decimal
score_p2: Math.round(avgP2Score * 10) / 10,
players_with_shame: playersWithShame,
players_without_shame: totalPlayersWithNames - playersWithShame
};
}
// Function to compute metrics for a selected player
function computeSelectedPlayerMetrics(uuid: string) {
const playerData = allPlayersWithScores.value.find(p => p.uuid === uuid);
if (!playerData) {
selectedPlayerMetrics.value = {
players_seated: 1,
score_p1: 0,
score_p2: 0,
players_with_shame: 0,
players_without_shame: 1
};
return;
}
let totalP1Scores = 0;
let totalP2Scores = 0;
let p1Count = 0;
let p2Count = 0;
const hasShame = playerData.shameTokens && playerData.shameTokens > 0;
if (playerData.roomScoreHistory) {
playerData.roomScoreHistory.forEach((roomScore: any) => {
roomScore.scores.forEach((score: any) => {
// Apply filters to scores
if (!scorePassesFilters(score, roomScore.roomId)) {
return;
}
if (score.role === 'P1') {
totalP1Scores += score.score;
p1Count++;
} else if (score.role === 'P2') {
totalP2Scores += score.score;
p2Count++;
}
});
});
}
selectedPlayerMetrics.value = {
players_seated: 1,
score_p1: p1Count > 0 ? Math.round((totalP1Scores / p1Count) * 10) / 10 : 0,
score_p2: p2Count > 0 ? Math.round((totalP2Scores / p2Count) * 10) / 10 : 0,
players_with_shame: hasShame ? 1 : 0,
players_without_shame: hasShame ? 0 : 1
};
}
// Combined counts for charts (events + metrics)
const combinedGlobalCounts = computed(() => ({
...eventFilters.globalEventCounts.value,
...additionalMetrics.value
}));
const combinedPlayerCounts = computed(() => ({
...playerEventCounts.value,
...selectedPlayerMetrics.value
}));
// Chart group totals
const offersTotal = computed(() => {
const propose = eventFilters.globalEventCounts.value.p1_propose || 0;
const noOffer = eventFilters.globalEventCounts.value.p1_no_offer || 0;
return propose + noOffer;
});
const responsesTotal = computed(() => {
const accept = eventFilters.globalEventCounts.value.p2_accept || 0;
const reject = eventFilters.globalEventCounts.value.p2_reject || 0;
const snatch = eventFilters.globalEventCounts.value.p2_snatch || 0;
return accept + reject + snatch;
});
const forceTotal = computed(() => {
const force = eventFilters.globalEventCounts.value.p2_force || 0;
const noForce = eventFilters.globalEventCounts.value.p2_no_force || 0;
return force + noForce;
});
const shameTotal = computed(() => {
const shame = eventFilters.globalEventCounts.value.p1_shame || 0;
const noShame = eventFilters.globalEventCounts.value.p1_no_shame || 0;
return shame + noShame;
});
const reportTotal = computed(() => {
const report = eventFilters.globalEventCounts.value.p1_report || 0;
const noReport = eventFilters.globalEventCounts.value.p1_no_report || 0;
return report + noReport;
});
const averageScoreTotal = computed(() => {
if (!allPlayersWithScores.value.length) return 0;
let totalScores = 0;
let totalScoreCount = 0;
// Sum all individual scores from all players regardless of role, applying filters
allPlayersWithScores.value.forEach((player: any) => {
if (player.roomScoreHistory) {
player.roomScoreHistory.forEach((roomScore: any) => {
roomScore.scores.forEach((score: any) => {
// Apply filters to scores
if (!scorePassesFilters(score, roomScore.roomId)) {
return;
}
totalScores += score.score;
totalScoreCount++;
});
});
}
});
return totalScoreCount > 0 ? Math.round((totalScores / totalScoreCount) * 10) / 10 : 0;
});
const totalPlayersCount = computed(() => {
return additionalMetrics.value.players_seated || 0;
});
// Active filters object
const activeFilters = computed(() => ({
dataSource: eventFilters.dataSource.value,
round: eventFilters.roundFilter.value.toString(),
game: eventFilters.gameFilter.value,
hasFilters: eventFilters.hasActiveFilters.value || !!selectedRoomId.value,
selectedPlayer: selectedUuid.value ? players.value.find(p => p.uuid === selectedUuid.value)?.name || 'Jugador' : undefined,
selectedRoom: selectedRoomId.value ? availableRooms.value.find(r => r.roomId === selectedRoomId.value)?.name || 'Sala' : undefined
}));
// Watch for changes in filters and data source
watch([eventFilters.dataSource, eventFilters.roundFilter, eventFilters.gameFilter, eventFilters.roomFilter], () => {
eventFilters.applyFilters(EVENTS);
// Recalculate metrics when filters change
computeMetricsFromScores();
});
// selectedUuid watch will be added after selectedUuid is declared
const rooms = ref<RoomInfo[]>([]);
const players = ref<{ uuid: string; name: string; color?: string }[]>([]);
const availableRooms = ref<{ roomId: string; name: string; playerCount?: number }[]>([]);
const search = ref('');
const roomSearch = ref('');
const playersFiltered = computed(() => {
const q = (search.value || '').toLowerCase();
if (!q) return players.value;
return players.value.filter(p => (p.name || '').toLowerCase().includes(q) || (p.uuid || '').toLowerCase().includes(q));
});
const roomsFiltered = computed(() => {
const q = (roomSearch.value || '').toLowerCase();
if (!q) return availableRooms.value;
return availableRooms.value.filter(r =>
(r.roomId || '').toLowerCase().includes(q) ||
(r.name || '').toLowerCase().includes(q)
);
});
// Reset page when search changes
watch(search, () => {
page.value = 1;
});
watch(roomSearch, () => {
roomPage.value = 1;
});
const page = ref(1);
const roomPage = ref(1);
const containerWidth = ref(1200); // Default width, will be updated dynamically
const dynamicPageSize = computed(() => {
// Estimate space needed per chip and controls
const chipWidth = 140; // Average chip width including gap
const clearBtnWidth = selectedUuid.value ? 150 : 0; // "Quitar selección" button
const searchWidth = 240; // Search input
const paginationWidth = pageCount.value > 1 ? 100 : 0; // Compact pagination
const margin = 40; // Container margins
const availableWidth = containerWidth.value - searchWidth - paginationWidth - clearBtnWidth - margin;
const maxChips = Math.max(3, Math.floor(availableWidth / chipWidth)); // Minimum 3 chips
return Math.min(maxChips, 15); // Maximum 15 chips per page
});
const roomDynamicPageSize = computed(() => {
// Similar calculation for rooms
const chipWidth = 160; // Room chips might be slightly wider
const clearBtnWidth = selectedRoomId.value ? 150 : 0;
const searchWidth = 240;
const paginationWidth = roomPageCount.value > 1 ? 100 : 0;
const margin = 40;
const availableWidth = containerWidth.value - searchWidth - paginationWidth - clearBtnWidth - margin;
const maxChips = Math.max(3, Math.floor(availableWidth / chipWidth));
return Math.min(maxChips, 10); // Maximum 10 room chips per page
});
const pageCount = computed(() => Math.max(1, Math.ceil((playersFiltered.value.length || 0) / dynamicPageSize.value)));
const roomPageCount = computed(() => Math.max(1, Math.ceil((roomsFiltered.value.length || 0) / roomDynamicPageSize.value)));
const playersPage = computed(() => {
const start = (page.value - 1) * dynamicPageSize.value;
return playersFiltered.value.slice(start, start + dynamicPageSize.value);
});
const roomsPage = computed(() => {
const start = (roomPage.value - 1) * roomDynamicPageSize.value;
return roomsFiltered.value.slice(start, start + roomDynamicPageSize.value);
});
const selectedUuid = ref('');
const selectedRoomId = ref('');
const playerLoading = ref(false);
const playerEventCounts = ref<Record<string, number>>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>));
const playersActionsByUuid = ref<Record<string, Record<string, number>>>({});
// Watch for selected player changes to update their metrics
watch(selectedUuid, (newUuid) => {
if (newUuid) {
computeSelectedPlayerMetrics(newUuid);
}
});
function initials(name: string): string {
const n = (name || '').trim();
if (!n) return '🙂';
const parts = n.split(/\s+/).slice(0, 2);
const chars = parts.map(p => p[0]?.toUpperCase()).join('');
return chars || '🙂';
}
function selectPlayer(uuid: string) {
if (selectedUuid.value === uuid) return;
selectedUuid.value = uuid;
loadPlayerHistory();
}
function clearPlayer() {
selectedUuid.value = '';
}
function selectRoom(roomId: string) {
if (selectedRoomId.value === roomId) return;
selectedRoomId.value = roomId;
eventFilters.roomFilter.value = roomId;
eventFilters.applyFilters(EVENTS);
}
function clearRoom() {
selectedRoomId.value = '';
eventFilters.roomFilter.value = 'all';
eventFilters.applyFilters(EVENTS);
}
function goHome() {
router.push('/');
}
function prevPage() { page.value = Math.max(1, page.value - 1); }
function nextPage() { page.value = Math.min(pageCount.value, page.value + 1); }
function prevRoomPage() { roomPage.value = Math.max(1, roomPage.value - 1); }
function nextRoomPage() { roomPage.value = Math.min(roomPageCount.value, roomPage.value + 1); }
// Ensure page doesn't exceed pageCount when players change
watch(pageCount, (newCount) => {
if (page.value > newCount) {
page.value = Math.max(1, newCount);
}
});
watch(roomPageCount, (newCount) => {
if (roomPage.value > newCount) {
roomPage.value = Math.max(1, newCount);
}
});
// Dynamic per-player overlay bar gradient and label color
const playerBarGradient = computed(() => {
const p = players.value.find(x => x.uuid === selectedUuid.value);
const c = p?.color || '#667eea';
return `linear-gradient(90deg, ${c}, ${c})`;
});
const apiBase = (import.meta as any).env?.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
const esRooms = ref<EventSource|null>(null);
const esUuids = ref<EventSource|null>(null);
const esActions = ref<EventSource|null>(null);
function closeStreams() {
try { esRooms.value?.close(); } catch {}
try { esUuids.value?.close(); } catch {}
try { esActions.value?.close(); } catch {}
esRooms.value = null;
esUuids.value = null;
esActions.value = null;
}
function setupStreams() {
loading.value = true;
// Rooms stream
closeStreams();
esRooms.value = new EventSource(`${apiBase}/dashboard-stream`);
esRooms.value.onmessage = (e) => {
try {
const data = JSON.parse((e as MessageEvent).data || '{}');
const details = data?.roomDetails || {};
// Room details are for active rooms, we'll get room data from player history instead
// Collect detailed events for active rooms
const detailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
const counts: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
Object.entries(details).forEach(([roomId, d]: [string, any]) => {
(Array.isArray(d?.systemMessages) ? d.systemMessages : []).forEach((m: any) => {
const k = (m?.kind || '').toString();
if (EVENTS.includes(k)) {
counts[k] = (counts[k] || 0) + 1;
detailedEvents.push({
kind: k,
round: m?.round,
gameVariant: m?.gameVariant || m?.variant,
roomId: m?.roomId || roomId // Use message roomId if available, otherwise use room key
});
}
});
});
eventFilters.updateActiveRoomsData(detailedEvents, counts);
// Compute additional metrics from active rooms (for current UI compatibility)
// computeMetrics(details); // Removed since we now use score history
// Don't extract rooms from active data - we'll get them from aggregated player history
// Apply filters and update display
if (eventFilters.dataSource.value === 'active-rooms') {
eventFilters.applyFilters(EVENTS);
}
// Build players list from room details (keep color if provided)
const playerMap = new Map<string, { name: string; color?: string }>();
Object.values(details).forEach((d: any) => {
(d?.players || []).forEach((p: any) => {
const uuid = (p?.uuid || p?.sessionId || '').toString();
if (uuid && !playerMap.has(uuid)) playerMap.set(uuid, { name: (p?.name || 'player'), color: p?.color });
});
});
const merged = new Map<string, { name: string; color?: string }>();
players.value.forEach(p => merged.set(p.uuid, { name: p.name, color: p.color }));
playerMap.forEach((obj, uuid) => merged.set(uuid, { name: obj.name, color: obj.color || merged.get(uuid)?.color }));
players.value = Array.from(merged.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name));
} finally {
loading.value = false;
}
};
esRooms.value.onerror = () => {};
// UUIDs stream
esUuids.value = new EventSource(`${apiBase}/uuids-stream`);
esUuids.value.onmessage = (e) => {
try {
const data = JSON.parse((e as MessageEvent).data || '{}');
const list = Array.isArray(data?.uuids) ? data.uuids : [];
const existing = new Map<string, { name: string; color?: string }>();
players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color }));
list.forEach((u: any) => {
const uuid = (u?.uuid || '').toString();
if (!uuid) return;
const prev = existing.get(uuid);
const name = (u?.name || prev?.name || 'player').toString();
existing.set(uuid, { name, color: prev?.color });
});
players.value = Array.from(existing.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name));
} catch {}
};
esUuids.value.onerror = () => {};
// Per-player actions stream
esActions.value = new EventSource(`${apiBase}/players-actions-stream`);
esActions.value.onmessage = (e) => {
try {
const data = JSON.parse((e as MessageEvent).data || '{}');
const list = Array.isArray(data?.players) ? data.players : [];
allPlayersActions.value = list.map((p: any) => ({ uuid: String(p.uuid||''), name: String(p.name||''), total: Number(p.total||0) }));
// Store complete player data with room score history
allPlayersWithScores.value = list;
// Collect all detailed events from all players
const allDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
const aggregatedCounts: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
// Update detailed counts map
const byUuid: Record<string, Record<string, number>> = {};
list.forEach((p: any) => {
const uuid = String(p?.uuid || '');
if (!uuid) return;
const src = p?.counts || {};
const normalized: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
// Add detailed history events
if (Array.isArray(p?.detailedHistory)) {
allDetailedEvents.push(...p.detailedHistory);
}
EVENTS.forEach(k => {
const count = Number(src[k] || 0);
normalized[k] = count;
aggregatedCounts[k] = (aggregatedCounts[k] || 0) + count;
});
byUuid[uuid] = normalized;
});
playersActionsByUuid.value = byUuid;
eventFilters.updateAggregatedData(allDetailedEvents, aggregatedCounts);
// Extract unique room IDs from aggregated events - this is our primary source for rooms
const roomIds = new Set<string>();
allDetailedEvents.forEach(event => {
if (event.roomId && event.roomId.trim()) {
roomIds.add(event.roomId);
}
});
// Build available rooms list from aggregated events
availableRooms.value = Array.from(roomIds).map(roomId => ({
roomId,
name: `Sala ${roomId.slice(0, 8)}`,
playerCount: allDetailedEvents.filter(e => e.roomId === roomId).length
}));
// Compute metrics from score history
computeMetricsFromScores();
// Apply filters and update display if viewing aggregated data
if (eventFilters.dataSource.value === 'aggregated') {
eventFilters.applyFilters(EVENTS);
}
// If a player is selected, update playerEventCounts live
if (selectedUuid.value) {
const counts = byUuid[selectedUuid.value];
if (counts) playerEventCounts.value = counts as any;
}
// Merge names into players list; preserve colors from previous streams
const existing = new Map<string, { name: string; color?: string }>();
players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color }));
list.forEach((u: any) => {
const uuid = (u?.uuid || '').toString();
if (!uuid) return;
const prev = existing.get(uuid);
const name = (u?.name || prev?.name || 'player').toString();
existing.set(uuid, { name, color: prev?.color });
});
players.value = Array.from(existing.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name));
} catch {}
};
esActions.value.onerror = () => {};
}
async function fetchRooms(): Promise<RoomInfo[]> {
const res = await fetch(`${apiBase}/rooms`);
return await res.json();
}
async function fetchRoomStats(roomId: string): Promise<RoomState> {
const res = await fetch(`${apiBase}/rooms/${roomId}/stats`);
return await res.json();
}
async function refreshAll() {
loading.value = true;
try {
const list = await fetchRooms();
rooms.value = (list || []).filter((r: any) => r?.name === 'game');
// reset counts
eventFilters.globalEventCounts.value = Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>;
const playerMap = new Map<string, { name: string; color?: string }>();
for (const r of rooms.value) {
try {
const s = await fetchRoomStats(r.roomId);
const msgs = (s?.systemMessages || []) as Array<{ kind: string }>;
msgs.forEach(m => {
const k = (m?.kind || '').toString();
if (EVENTS.includes(k)) {
eventFilters.globalEventCounts.value[k] = (eventFilters.globalEventCounts.value[k] || 0) + 1;
}
});
// collect players for filter
(s?.players || []).forEach((p: any) => {
const uuid = (p?.uuid || p?.sessionId || '').toString();
if (uuid && !playerMap.has(uuid)) playerMap.set(uuid, { name: (p?.name || 'player'), color: p?.color });
});
} catch {}
}
players.value = Array.from(playerMap.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color }));
} finally {
loading.value = false;
}
}
async function loadPlayerHistory() {
playerLoading.value = true;
const next: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
const counts = playersActionsByUuid.value[selectedUuid.value || ''];
if (counts) {
EVENTS.forEach(k => { next[k] = Number(counts[k] || 0); });
}
playerEventCounts.value = next as any;
playerLoading.value = false;
}
// Update container width on resize
function updateContainerWidth() {
containerWidth.value = window.innerWidth;
}
onMounted(() => {
setupStreams();
// Initialize with aggregated data as default
eventFilters.globalEventCounts.value = { ...eventFilters.globalEventCountsAggregated.value };
// Set initial container width and add resize listener
updateContainerWidth();
window.addEventListener('resize', updateContainerWidth);
});
onUnmounted(() => {
closeStreams();
window.removeEventListener('resize', updateContainerWidth);
});
// Reset to first page when search changes or players list length shrinks below current page
watch(() => search.value, () => { page.value = 1; });
watch(() => playersFiltered.value.length, () => {
if (page.value > pageCount.value) page.value = pageCount.value;
});
// Removed totals table and sorting; keep actions stream for per-player counts only
const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]);
function downloadJSON() {
// Create a comprehensive snapshot of current interface data
const currentData = {
metadata: {
timestamp: new Date().toISOString(),
dataSource: eventFilters.dataSource.value,
filters: {
room: eventFilters.roomFilter.value,
round: eventFilters.roundFilter.value,
game: eventFilters.gameFilter.value,
hasActiveFilters: eventFilters.hasActiveFilters.value,
filterSummary: eventFilters.filterSummary.value,
selectedPlayer: selectedUuid.value ? {
uuid: selectedUuid.value,
name: players.value.find(p => p.uuid === selectedUuid.value)?.name || 'Jugador'
} : null,
selectedRoom: selectedRoomId.value ? {
roomId: selectedRoomId.value,
name: availableRooms.value.find(r => r.roomId === selectedRoomId.value)?.name || 'Sala'
} : null
}
},
// Current chart totals being displayed
chartTotals: {
offers: offersTotal.value,
responses: responsesTotal.value,
force: forceTotal.value,
shame: shameTotal.value,
report: reportTotal.value,
averageScore: averageScoreTotal.value,
totalPlayers: totalPlayersCount.value
},
// Global event counts (filtered)
globalEventCounts: eventFilters.globalEventCounts.value,
// Selected player event counts (if applicable)
selectedPlayerEventCounts: selectedUuid.value ? playerEventCounts.value : null,
// Additional metrics being displayed
additionalMetrics: additionalMetrics.value,
// Selected player metrics (if applicable)
selectedPlayerMetrics: selectedUuid.value ? selectedPlayerMetrics.value : null,
// Combined counts used in charts
combinedGlobalCounts: combinedGlobalCounts.value,
combinedPlayerCounts: selectedUuid.value ? combinedPlayerCounts.value : null,
// Players data
players: players.value,
availableRooms: availableRooms.value,
// Current filtered data if available
currentSourceEvents: eventFilters.currentSourceEvents.value,
// Per-player action counts
playersActionsByUuid: playersActionsByUuid.value,
// All players with scores data
allPlayersWithScores: allPlayersWithScores.value.map(player => ({
uuid: player.uuid,
name: player.name,
total: player.total,
shameTokens: player.shameTokens,
counts: player.counts,
roomScoreHistory: player.roomScoreHistory
}))
};
// Convert to formatted JSON string
const jsonString = JSON.stringify(currentData, null, 2);
// Create and download file
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
// Generate filename with current filters and timestamp
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
const dataSourceLabel = eventFilters.dataSource.value === 'aggregated' ? 'agregados' : 'activos';
const filterLabel = eventFilters.hasActiveFilters.value ? `_${eventFilters.filterSummary.value.replace(/\s+/g, '_')}` : '';
const filename = `leaderboard_${dataSourceLabel}${filterLabel}_${timestamp}.json`;
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
</script>
<style scoped>
/* Light theme aligned with other pages (UUID selector, lobby, game) */
.leaderboard.light { min-height: 100vh; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color:#0f172a; display:flex; flex-direction:column; }
.glass.light { background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(229, 231, 235, 0.95); box-shadow: 0 18px 50px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.75); backdrop-filter: blur(18px) saturate(120%); -webkit-backdrop-filter: blur(18px) saturate(120%); border-radius: 16px; }
.header { display:flex; align-items:center; justify-content:space-between; gap: 8px; flex-wrap: wrap; padding: 8px 10px; margin-bottom: 10px; }
.header h1 { margin: 0; font-size: 18px; line-height: 1.2; flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.header-left { display:flex; align-items:center; gap: 10px; flex: 1 1 auto; min-width: 200px; }
.btn-back { background:#667eea; color:#fff; border:none; border-radius:6px; padding:6px 10px; font-weight:600; cursor:pointer; transition: all 0.3s ease; font-size: 12px; }
.btn-back:hover { background:#5b6bda; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); }
.actions { display:flex; gap: 6px; flex-wrap: wrap; align-items: center; justify-content: flex-end; flex: 1 1 280px; }
.actions .btn { background:#667eea; color:#fff; border:none; border-radius:6px; padding:4px 8px; font-weight:800; font-size: 11px; cursor:pointer; }
.actions .btn.toggle { background:#eef2ff; color:#3949ab; border:1px solid #c7d2fe; }
.actions .btn.toggle.active { background:#3949ab; color:#fff; border-color:#2e3f9a; }
.controls { display:grid; grid-template-columns: 1fr; gap: 10px; padding: 10px 12px; margin-bottom: 14px; }
.legend { font-size: 13px; color:#334155; display:flex; align-items:center; gap:10px; }
.key { width: 12px; height: 12px; border-radius: 999px; display:inline-block; }
.key.global { background: linear-gradient(90deg, #34d399, #10b981); box-shadow: 0 0 8px rgba(16,185,129,0.35); }
.key.player { background: linear-gradient(90deg, #a78bfa, #6366f1); box-shadow: 0 0 8px rgba(99,102,241,0.35); }
.key.room { background: linear-gradient(90deg, #f59e0b, #d97706); box-shadow: 0 0 8px rgba(245,158,11,0.35); }
.sep { opacity: 0.6; }
.player-chips, .room-chips {
display: flex;
flex-direction: column;
gap: 12px;
}
.room-chips {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(203, 213, 225, 0.5);
}
.search-controls {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
}
.search {
padding: 8px 10px;
border: 1px solid #cbd5e1;
background: #fff;
color: #0f172a;
border-radius: 10px;
min-width: 240px;
outline: none;
flex: 1;
max-width: 300px;
}
.search::placeholder {
color: #64748b;
}
.chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.chip { display:flex; align-items:center; gap:8px; background: color-mix(in srgb, var(--primary) 6%, white); border:1px solid color-mix(in srgb, var(--primary) 24%, #e5e7eb); padding:8px 12px; border-radius: 999px; color:#111827; cursor:pointer; transition: transform .18s ease, background .18s ease, box-shadow .18s ease; }
.chip:hover { transform: translateY(-1px); background: color-mix(in srgb, var(--primary) 10%, white); box-shadow: 0 6px 18px rgba(102,126,234,0.18); }
.chip.active { background: color-mix(in srgb, var(--primary) 18%, white); border-color: color-mix(in srgb, var(--primary) 45%, #c7d2fe); box-shadow: 0 6px 22px rgba(99,102,241,0.22); }
.chip.clear { background:#fff; border-style:dashed; color:#334155; }
.chip.room-chip { --primary: #f59e0b; }
.chip.room-chip .count {
background: color-mix(in srgb, var(--primary) 20%, white);
color: color-mix(in srgb, var(--primary) 80%, #111);
font-size: 11px;
font-weight: 800;
padding: 2px 6px;
border-radius: 10px;
margin-left: 4px;
}
.avatar { width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--primary) 25%, #eef2ff); display:grid; place-items:center; font-weight:900; color: color-mix(in srgb, var(--primary) 80%, #111); }
/* Pagination styles */
.pagination {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(229,231,235,0.9);
border-radius: 999px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.6);
backdrop-filter: blur(10px) saturate(120%);
-webkit-backdrop-filter: blur(10px) saturate(120%);
}
.pagination.compact {
gap: 6px;
padding: 4px 8px;
}
.pg-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #cbd5e1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 900;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(102,126,234,0.25);
}
.pg-btn.compact {
width: 24px;
height: 24px;
font-size: 14px;
font-weight: 800;
box-shadow: 0 2px 8px rgba(102,126,234,0.2);
}
.pg-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(102,126,234,0.35);
}
.pg-btn.compact:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(102,126,234,0.3);
}
.pg-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #e5e7eb;
color: #94a3b8;
box-shadow: none;
}
.pg-ind {
font-weight: 600;
color: #334155;
font-size: 12px;
min-width: 35px;
text-align: center;
}
/* Filters section styles */
.filters-section {
transition: all 0.3s ease;
}
.btn-collapse {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border: none;
border-radius: 6px;
background: #eef2ff;
color: #3949ab;
border: 1px solid #c7d2fe;
font-weight: 800;
font-size: 11px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-collapse:hover {
background: rgba(255,255,255,0.9);
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.collapse-icon { transition: transform 0.3s ease; font-size: 11px; }
.collapse-icon.rotated {
transform: rotate(-90deg);
}
.filters-content {
display: flex;
flex-direction: column;
gap: 0;
}
/* Transition for filters */
.filters-slide-enter-active,
.filters-slide-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.filters-slide-enter-from,
.filters-slide-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
}
.filters-slide-enter-to,
.filters-slide-leave-from {
opacity: 1;
max-height: 200px;
transform: translateY(0);
}
@media (max-width: 768px) {
.header { padding: 6px 8px; margin-bottom: 8px; }
.header h1 { font-size: 16px; }
.btn-back { padding: 5px 8px; font-size: 11px; border-radius: 6px; }
.actions { gap: 6px; }
.actions .btn { padding: 4px 6px; font-size: 10px; border-radius: 5px; }
.search-controls {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.search {
min-width: auto;
max-width: none;
}
.pagination.compact {
align-self: center;
}
}
@media (max-width: 640px) {
.header { gap: 6px; }
.header-left { min-width: 140px; }
.actions { justify-content: flex-start; }
.btn-collapse .collapse-text {
display: none;
}
.btn-collapse {
padding: 4px 6px;
min-width: 34px;
justify-content: center;
font-size: 10px;
border-radius: 5px;
}
}
@media (max-width: 480px) {
.header h1 { font-size: 14px; }
.btn-back { padding: 4px 6px; font-size: 10px; }
.actions { gap: 4px; }
.actions .btn { padding: 3px 5px; font-size: 9px; border-radius: 4px; }
.btn-collapse { padding: 3px 5px; min-width: 30px; font-size: 9px; border-radius: 4px; }
/* Ultra-compact: single-line header on very small screens */
.header { flex-wrap: nowrap; padding: 4px 6px; }
.header-left { flex: 0 1 auto; }
.btn-back .label { display: none; }
.actions .btn .label { display: none; }
.header h1 { font-size: 13px; }
.header h1 .emoji { display: none; }
.player-chips {
gap: 8px;
}
.chips {
gap: 8px;
justify-content: center;
}
.chip {
padding: 6px 10px;
}
.pg-btn.compact {
width: 20px;
height: 20px;
font-size: 12px;
}
.pg-ind {
font-size: 11px;
min-width: 30px;
}
}
</style>