diff --git a/client/src/components/EventChart.vue b/client/src/components/EventChart.vue index 0068d40..b181eb2 100644 --- a/client/src/components/EventChart.vue +++ b/client/src/components/EventChart.vue @@ -15,6 +15,9 @@ 👤 {{ activeFilters.selectedPlayer }} + + 🏠 {{ activeFilters.selectedRoom }} +
Cargando datos…
@@ -124,6 +127,7 @@ interface Props { game: string; hasFilters: boolean; selectedPlayer?: string; + selectedRoom?: string; }; } diff --git a/client/src/composables/useEventFilters.ts b/client/src/composables/useEventFilters.ts index a0fb8b2..3f38d0a 100644 --- a/client/src/composables/useEventFilters.ts +++ b/client/src/composables/useEventFilters.ts @@ -1,4 +1,4 @@ -import { ref, computed, watch } from 'vue'; +import { ref, computed } from 'vue'; export interface DetailedEvent { kind: string; @@ -6,17 +6,20 @@ export interface DetailedEvent { gameVariant?: string; playerUuid?: string; playerName?: string; + roomId?: string; } export type DataSource = 'aggregated' | 'active-rooms'; export type RoundFilter = 'all' | 1 | 2 | 3; export type GameFilter = 'all' | 'G1' | 'G2' | 'G3' | 'G4' | 'G5'; +export type RoomFilter = 'all' | string; export function useEventFilters() { // Filter states const dataSource = ref('aggregated'); const roundFilter = ref('all'); const gameFilter = ref('all'); + const roomFilter = ref('all'); // Event data stores const detailedEventsAggregated = ref([]); @@ -33,7 +36,7 @@ export function useEventFilters() { ? detailedEventsAggregated.value : detailedEventsActiveRooms.value; - // Filter events based on round and game + // Filter events based on round, game, and room const filteredEvents = sourceEvents.filter(event => { if (roundFilter.value !== 'all' && event.round !== roundFilter.value) { return false; @@ -41,6 +44,9 @@ export function useEventFilters() { if (gameFilter.value !== 'all' && event.gameVariant !== gameFilter.value) { return false; } + if (roomFilter.value !== 'all' && event.roomId !== roomFilter.value) { + return false; + } return true; }); @@ -71,6 +77,7 @@ export function useEventFilters() { function resetFilters() { roundFilter.value = 'all'; gameFilter.value = 'all'; + roomFilter.value = 'all'; } // Computed properties @@ -83,13 +90,14 @@ export function useEventFilters() { ); const hasActiveFilters = computed(() => - roundFilter.value !== 'all' || gameFilter.value !== 'all' + roundFilter.value !== 'all' || gameFilter.value !== 'all' || roomFilter.value !== 'all' ); const filterSummary = computed(() => { const parts = []; if (roundFilter.value !== 'all') parts.push(`Round ${roundFilter.value}`); if (gameFilter.value !== 'all') parts.push(`Game ${gameFilter.value}`); + if (roomFilter.value !== 'all') parts.push(`Room ${roomFilter.value.slice(0, 8)}`); return parts.length > 0 ? parts.join(' + ') : 'Sin filtros'; }); @@ -98,6 +106,7 @@ export function useEventFilters() { dataSource, roundFilter, gameFilter, + roomFilter, detailedEventsAggregated, detailedEventsActiveRooms, globalEventCounts, diff --git a/client/src/views/Leaderboard.vue b/client/src/views/Leaderboard.vue index b5e6bef..2f5f372 100644 --- a/client/src/views/Leaderboard.vue +++ b/client/src/views/Leaderboard.vue @@ -42,6 +42,8 @@ Global · Jugador + · + Sala
@@ -68,6 +70,33 @@
+ + +
+
+ + +
+
+ + +
+
@@ -85,8 +114,9 @@ dataSource: eventFilters.dataSource.value, round: eventFilters.roundFilter.value.toString(), game: eventFilters.gameFilter.value, - hasFilters: eventFilters.hasActiveFilters.value, - selectedPlayer: selectedUuid ? players.find(p => p.uuid === selectedUuid)?.name || 'Jugador' : undefined + hasFilters: eventFilters.hasActiveFilters.value || !!selectedRoomId, + selectedPlayer: selectedUuid ? players.find(p => p.uuid === selectedUuid)?.name || 'Jugador' : undefined, + selectedRoom: selectedRoomId ? availableRooms.find(r => r.roomId === selectedRoomId)?.name || 'Sala' : undefined }" /> @@ -235,7 +265,7 @@ function computeMetrics(roomDetails: any) { } // Function to compute metrics for a selected player -function computeSelectedPlayerMetrics(uuid: string) { +function computeSelectedPlayerMetrics(_uuid: string) { // Individual player metrics are simpler - just show if they're seated, their score, etc. // For now, set basic values. In a real implementation, we'd need to query current player state selectedPlayerMetrics.value = { @@ -258,7 +288,7 @@ const combinedPlayerCounts = computed(() => ({ })); // Watch for changes in filters and data source -watch([eventFilters.dataSource, eventFilters.roundFilter, eventFilters.gameFilter], () => { +watch([eventFilters.dataSource, eventFilters.roundFilter, eventFilters.gameFilter, eventFilters.roomFilter], () => { eventFilters.applyFilters(EVENTS); }); @@ -266,18 +296,33 @@ watch([eventFilters.dataSource, eventFilters.roundFilter, eventFilters.gameFilte const rooms = ref([]); 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 @@ -293,13 +338,35 @@ const dynamicPageSize = computed(() => { 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>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record)); const playersActionsByUuid = ref>>({}); @@ -319,6 +386,10 @@ function initials(name: string): string { return chars || '🙂'; } +function shortRoomId(roomId: string): string { + return roomId.slice(0, 8); +} + function selectPlayer(uuid: string) { if (selectedUuid.value === uuid) return; selectedUuid.value = uuid; @@ -329,8 +400,23 @@ 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 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) => { @@ -339,6 +425,12 @@ watch(pageCount, (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); @@ -372,11 +464,13 @@ function setupStreams() { 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 }> = []; + const detailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = []; const counts: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any; - Object.values(details).forEach((d: 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)) { @@ -384,7 +478,8 @@ function setupStreams() { detailedEvents.push({ kind: k, round: m?.round, - gameVariant: m?.gameVariant || m?.variant + gameVariant: m?.gameVariant || m?.variant, + roomId: m?.roomId || roomId // Use message roomId if available, otherwise use room key }); } }); @@ -395,6 +490,8 @@ function setupStreams() { // Compute additional metrics computeMetrics(details); + // 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); @@ -445,8 +542,9 @@ function setupStreams() { 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) })); + // Collect all detailed events from all players - const allDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string }> = []; + const allDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = []; const aggregatedCounts: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any; // Update detailed counts map @@ -473,6 +571,21 @@ function setupStreams() { 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(); + 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 + })); + // Apply filters and update display if viewing aggregated data if (eventFilters.dataSource.value === 'aggregated') { eventFilters.applyFilters(EVENTS); @@ -580,11 +693,12 @@ watch(() => playersFiltered.value.length, () => { // Removed totals table and sorting; keep actions stream for per-player counts only const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]); + function downloadCSV() { const currentEvents = eventFilters.currentSourceEvents.value; // Create CSV headers - const headers = ['Type', 'Event', 'Count', 'Round', 'GameVariant', 'PlayerUuid', 'PlayerName', 'DataSource']; + const headers = ['Type', 'Event', 'Count', 'Round', 'GameVariant', 'PlayerUuid', 'PlayerName', 'RoomId', 'DataSource']; // Create CSV rows - one row per individual event occurrence const rows: string[][] = []; @@ -600,6 +714,7 @@ function downloadCSV() { event.gameVariant || '', event.playerUuid || '', event.playerName || '', + event.roomId || '', eventFilters.dataSource.value ]); } @@ -621,6 +736,7 @@ function downloadCSV() { '', // GameVariant info not available in aggregated player data uuid, player?.name || '', + '', // Room info not available in aggregated player data 'player-aggregated' ]); } @@ -644,6 +760,7 @@ function downloadCSV() { '', '', '', + '', eventFilters.dataSource.value ]); } @@ -695,14 +812,21 @@ function downloadCSV() { .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 { +.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; @@ -736,6 +860,16 @@ function downloadCSV() { .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); } diff --git a/server/src/adminApi.ts b/server/src/adminApi.ts index 4b8d44c..6819698 100644 --- a/server/src/adminApi.ts +++ b/server/src/adminApi.ts @@ -828,7 +828,8 @@ async function sendPlayersActionsUpdate(client?: Response) { detailedHistory.push({ kind, round: (entry as any)?.round, - gameVariant: (entry as any)?.gameVariant || (entry as any)?.variant + gameVariant: (entry as any)?.gameVariant || (entry as any)?.variant, + roomId: (entry as any)?.roomId }); } const total = ACTION_EVENTS.reduce((acc, k) => acc + (counts[k] || 0), 0);