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);