diff --git a/client/src/views/Leaderboard.vue b/client/src/views/Leaderboard.vue
index b1a6ad1..f021f09 100644
--- a/client/src/views/Leaderboard.vue
+++ b/client/src/views/Leaderboard.vue
@@ -21,9 +21,66 @@
-
+
+
+
+
+
+
+
+
+
+
+
+ Global
+ ·
+ Ronda
+ ·
+ Juego
+ ·
+ Jugadores
+ ·
+ Salas
+
+
+
+
-
-
-
-
-
-
- Global
- ·
- Jugadores
- ·
- Salas
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -121,6 +142,18 @@
totalPlayers: totalPlayersCount
}"
/>
+
+
+
@@ -130,7 +163,6 @@ 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 GameLogo from '../components/GameLogo.vue';
import AppCredits from '../components/AppCredits.vue';
import { useEventFilters } from '../composables/useEventFilters';
@@ -141,6 +173,95 @@ const loading = ref(false);
const eventFilters = useEventFilters();
const filtersCollapsed = ref(false);
+// Time mode and range handling
+type TimeMode = 'active' | 'range';
+const timeMode = ref('range');
+const rangeFromStr = ref('');
+const rangeToStr = ref('');
+const liveEnd = ref(true);
+let liveTimer: any = null;
+const fullAggregatedEvents = ref>([]);
+
+function fmtLocal(dt: Date) {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ const y = dt.getFullYear();
+ const m = pad(dt.getMonth() + 1);
+ const d = pad(dt.getDate());
+ const hh = pad(dt.getHours());
+ const mm = pad(dt.getMinutes());
+ return `${y}-${m}-${d}T${hh}:${mm}`;
+}
+
+function initDefaultRange() {
+ const now = new Date();
+ const from = new Date(now.getTime() - 60 * 60 * 1000); // 1h atrás
+ rangeFromStr.value = fmtLocal(from);
+ rangeToStr.value = fmtLocal(now);
+}
+
+function setTimeMode(mode: TimeMode) {
+ timeMode.value = mode;
+ applyTimeMode();
+}
+
+function applyTimeMode() {
+ if (timeMode.value === 'active') {
+ eventFilters.dataSource.value = 'active-rooms';
+ eventFilters.roomFilter.value = [];
+ selectedRoomIds.value = [];
+ eventFilters.applyFilters(EVENTS);
+ return;
+ }
+ eventFilters.dataSource.value = 'aggregated';
+ // Parse range
+ const fromMs = Date.parse(rangeFromStr.value || '');
+ const toMs = liveEnd.value ? Date.now() : Date.parse(rangeToStr.value || '');
+ if (liveEnd.value) {
+ rangeToStr.value = fmtLocal(new Date(toMs));
+ }
+ const valid = !Number.isNaN(fromMs) && !Number.isNaN(toMs) && toMs >= fromMs;
+ const ranged = valid
+ ? fullAggregatedEvents.value.filter(ev => typeof ev.timestamp === 'number' && (ev.timestamp as number) >= fromMs && (ev.timestamp as number) <= toMs)
+ : fullAggregatedEvents.value;
+ const counts: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
+ ranged.forEach(ev => { if (EVENTS.includes(ev.kind)) { counts[ev.kind] = (counts[ev.kind] || 0) + 1; } });
+ eventFilters.updateAggregatedData(ranged as any, counts);
+ // Build rooms and sync roomFilter to current range
+ const lastSeenIndex: Record = {};
+ const ids = new Set();
+ ranged.forEach((ev, i) => {
+ const rid = String(ev?.roomId || '').trim();
+ if (!rid) return;
+ ids.add(rid);
+ lastSeenIndex[rid] = i;
+ });
+ availableRooms.value = Array.from(ids).map(rid => ({
+ roomId: rid,
+ name: `Sala ${rid.slice(0, 8)}`,
+ playerCount: ranged.filter(e => e?.roomId === rid).length,
+ _lastSeen: lastSeenIndex[rid] ?? -1
+ })).sort((a: any, b: any) => (a._lastSeen - b._lastSeen)).map(({ _lastSeen, ...rest }: any) => rest);
+ selectedRoomIds.value = Array.from(ids);
+ eventFilters.roomFilter.value = [...selectedRoomIds.value];
+ eventFilters.applyFilters(EVENTS);
+}
+
+function toggleLiveEnd() {
+ liveEnd.value = !liveEnd.value;
+ if (liveEnd.value) {
+ if (liveTimer) { clearInterval(liveTimer); liveTimer = null; }
+ // Update immediately and every 15s
+ rangeToStr.value = fmtLocal(new Date());
+ applyTimeMode();
+ liveTimer = setInterval(() => {
+ rangeToStr.value = fmtLocal(new Date());
+ applyTimeMode();
+ }, 15000);
+ } else {
+ if (liveTimer) { clearInterval(liveTimer); liveTimer = null; }
+ }
+}
+
const EVENTS = [
'p1_propose', 'p1_no_offer',
@@ -477,46 +598,6 @@ const playersPage = computed(() => {
const selectedUuids = ref([]);
const selectedRoomIds = ref([]);
-const roomSliceStart = ref(0);
-const roomSliceEnd = ref(0);
-const maxIndex = computed(() => Math.max(0, availableRooms.value.length - 1));
-const sliceInitialized = ref(false);
-const roomSliceIds = computed(() => {
- const a = Math.max(0, Math.min(roomSliceStart.value | 0, maxIndex.value));
- const b = Math.max(0, Math.min(roomSliceEnd.value | 0, maxIndex.value));
- const start = Math.min(a, b);
- const end = Math.max(a, b);
- return availableRooms.value.slice(start, end + 1).map(r => r.roomId);
-});
-const sliceStartLabel = computed(() => roomSliceIds.value.length ? Math.min(roomSliceStart.value, roomSliceEnd.value) + 1 : 0);
-const sliceEndLabel = computed(() => roomSliceIds.value.length ? Math.max(roomSliceStart.value, roomSliceEnd.value) + 1 : 0);
-const startPct = computed(() => maxIndex.value > 0 ? (Math.min(roomSliceStart.value, roomSliceEnd.value) / maxIndex.value) * 100 : 0);
-const endPct = computed(() => maxIndex.value > 0 ? (Math.max(roomSliceStart.value, roomSliceEnd.value) / maxIndex.value) * 100 : 0);
-function onSliceStart() {
- if (roomSliceStart.value > roomSliceEnd.value) roomSliceStart.value = roomSliceEnd.value;
-}
-function onSliceEnd() {
- if (roomSliceEnd.value < roomSliceStart.value) roomSliceEnd.value = roomSliceStart.value;
-}
-
-function selectRecent(count: number) {
- const m = maxIndex.value;
- if (m < 0) return;
- const start = Math.max(0, m - (count - 1));
- roomSliceStart.value = start;
- roomSliceEnd.value = m;
-}
-
-function addRecent(count: number) {
- const m = maxIndex.value;
- if (m < 0) return;
- // Current selection length anchored at end
- const currentLen = roomSliceIds.value.length;
- const newLen = Math.min(m + 1, Math.max(0, currentLen) + Math.max(1, count));
- const start = Math.max(0, m - (newLen - 1));
- roomSliceStart.value = start;
- roomSliceEnd.value = m;
-}
const playerLoading = ref(false);
const playerEventCounts = ref>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record));
const playersActionsByUuid = ref>>({});
@@ -549,33 +630,6 @@ function clearPlayers() {
}
-function clearRooms() {
- suppressSliceSync = true;
- selectedRoomIds.value = [];
- eventFilters.roomFilter.value = [];
- eventFilters.applyFilters(EVENTS);
- // allow user to re-enable by moving sliders
- requestAnimationFrame(() => { suppressSliceSync = false; });
-}
-
-// Keep event filters in sync with slice selection
-let suppressSliceSync = false;
-// Initialize full-range selection as soon as rooms are available (first time)
-watch(maxIndex, (m) => {
- if (m >= 0 && !sliceInitialized.value) {
- roomSliceStart.value = 0;
- roomSliceEnd.value = m;
- selectedRoomIds.value = [...roomSliceIds.value];
- eventFilters.roomFilter.value = [...selectedRoomIds.value];
- sliceInitialized.value = true;
- }
-});
-watch([roomSliceStart, roomSliceEnd, () => availableRooms.value.length], () => {
- if (suppressSliceSync) return;
- selectedRoomIds.value = [...roomSliceIds.value];
- eventFilters.roomFilter.value = [...selectedRoomIds.value];
- eventFilters.applyFilters(EVENTS);
-});
function goHome() {
router.push('/');
@@ -604,187 +658,151 @@ const playerBarGradient = computed(() => '#8b5cf6');
const apiBase = (import.meta as any).env?.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
-const esRooms = ref(null);
-const esUuids = ref(null);
const esActions = ref(null);
+const rawActionsPayload = ref(null);
+const showRaw = ref(false);
+const prettyRaw = computed(() => {
+ try {
+ return JSON.stringify(rawActionsPayload.value, null, 2);
+ } catch {
+ return String(rawActionsPayload.value || '');
+ }
+});
+function copyRaw() {
+ try {
+ navigator.clipboard.writeText(prettyRaw.value);
+ } catch {}
+}
+
+function quickRange(minutes: number) {
+ const now = new Date();
+ const from = new Date(now.getTime() - minutes * 60 * 1000);
+ rangeFromStr.value = fmtLocal(from);
+ rangeToStr.value = fmtLocal(now);
+ applyTimeMode();
+}
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 = 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();
- 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();
- 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();
- 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
+ // Único stream: players-actions-stream
esActions.value = new EventSource(`${apiBase}/players-actions-stream`);
esActions.value.onmessage = (e) => {
try {
const data = JSON.parse((e as MessageEvent).data || '{}');
+ rawActionsPayload.value = 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 = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
-
- // Update detailed counts map
+
+ // Construir mapa de colores desde snapshot de salas activas (si está)
+ const colorMap = new Map();
+ const activeRooms = data?.activeRooms?.rooms || [];
+ activeRooms.forEach((r: any) => {
+ (Array.isArray(r?.players) ? r.players : []).forEach((p: any) => {
+ const uuid = String(p?.uuid || '');
+ if (uuid && !colorMap.has(uuid)) colorMap.set(uuid, p?.color || undefined);
+ });
+ });
+
+ // Lista de jugadores para chips con color (si disponible)
+ players.value = list
+ .map((p: any) => ({
+ uuid: String(p?.uuid || ''),
+ name: String(p?.name || 'player'),
+ color: colorMap.get(String(p?.uuid || '')) || undefined
+ }))
+ .filter((p: any) => !!p.uuid)
+ .sort((a: any, b: any) => a.name.localeCompare(b.name));
+
+ // Mapear counts por jugador
const byUuid: Record> = {};
list.forEach((p: any) => {
const uuid = String(p?.uuid || '');
if (!uuid) return;
const src = p?.counts || {};
const normalized: Record = 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;
- });
+ EVENTS.forEach(k => { normalized[k] = Number(src[k] || 0); });
byUuid[uuid] = normalized;
});
-
playersActionsByUuid.value = byUuid;
- eventFilters.updateAggregatedData(allDetailedEvents, aggregatedCounts);
-
- // Extract unique room IDs from aggregated events and track "newness" by last seen index
- const roomIds = new Set();
- const lastSeenIndex: Record = {};
- allDetailedEvents.forEach((event, idx) => {
- const rid = (event.roomId || '').trim();
- if (!rid) return;
- roomIds.add(rid);
- lastSeenIndex[rid] = idx; // increasing idx means newer
- });
- // Build available rooms list sorted by lastSeenIndex ASC (older first, newest get the highest index)
- availableRooms.value = Array.from(roomIds)
- .map(roomId => ({
- roomId,
- name: `Sala ${roomId.slice(0, 8)}`,
- playerCount: allDetailedEvents.filter(e => e.roomId === roomId).length,
- _lastSeen: lastSeenIndex[roomId] ?? -1
- }))
- .sort((a, b) => (a._lastSeen - b._lastSeen))
- .map(({ _lastSeen, ...rest }) => rest);
- // Initialize slice window: full range by default, preserve user selection if present
- if (availableRooms.value.length > 0 && selectedRoomIds.value.length === 0) {
- roomSliceStart.value = 0;
- roomSliceEnd.value = availableRooms.value.length - 1;
+ // Datos agregados desde el payload
+ const aggEvents = Array.isArray(data?.aggregated?.detailedEvents) ? data.aggregated.detailedEvents : [];
+ // Store full events with timestamps for range filtering
+ fullAggregatedEvents.value = aggEvents as any;
+ // Apply current time mode (range/active)
+ applyTimeMode();
+
+ // Datos de salas activas → eventos activos y counts
+ if (data?.activeRooms?.rooms) {
+ const activeDetailed: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
+ (data.activeRooms.rooms || []).forEach((r: any) => {
+ (Array.isArray(r?.systemMessages) ? r.systemMessages : []).forEach((m: any) => {
+ const k = String(m?.kind || '');
+ if (EVENTS.includes(k)) {
+ activeDetailed.push({
+ kind: k,
+ round: m?.round,
+ gameVariant: m?.gameVariant || m?.variant,
+ roomId: m?.roomId || r?.roomId
+ });
+ }
+ });
+ });
+ const activeCounts = data?.activeRooms?.counts || Object.fromEntries(EVENTS.map(k => [k, 0]));
+ eventFilters.updateActiveRoomsData(activeDetailed, activeCounts);
}
-
- // Compute metrics from score history
+
+ // Construir listado de salas desde aggregated.rooms si está, si no derivar de eventos
+ const roomsList = Array.isArray(data?.aggregated?.rooms) ? data.aggregated.rooms : null;
+ if (roomsList) {
+ availableRooms.value = roomsList
+ .slice()
+ .sort((a: any, b: any) => (a?.lastSeenIndex || 0) - (b?.lastSeenIndex || 0))
+ .map((r: any) => ({
+ roomId: String(r?.roomId || ''),
+ name: `Sala ${String(r?.roomId || '').slice(0, 8)}`,
+ playerCount: Number(r?.messageCount || 0)
+ }))
+ .filter((r: any) => !!r.roomId);
+ } else {
+ const lastSeenIndex: Record = {};
+ const ids = new Set();
+ aggEvents.forEach((ev: any, i: number) => {
+ const rid = String(ev?.roomId || '').trim();
+ if (!rid) return;
+ ids.add(rid);
+ lastSeenIndex[rid] = i;
+ });
+ availableRooms.value = Array.from(ids).map(rid => ({
+ roomId: rid,
+ name: `Sala ${rid.slice(0, 8)}`,
+ playerCount: aggEvents.filter((e: any) => e?.roomId === rid).length,
+ _lastSeen: lastSeenIndex[rid] ?? -1
+ })).sort((a: any, b: any) => (a._lastSeen - b._lastSeen)).map(({ _lastSeen, ...rest }: any) => rest);
+ }
+
+ // Sin slider: la selección de salas se sincroniza con el rango de tiempo
+
+ // Recalcular métricas desde 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
- // Update selected players combined counts
+
+ // Aplicar filtros según dataSource actual
+ eventFilters.applyFilters(EVENTS);
+
+ // Actualizar conteos del/los jugador(es) seleccionado(s)
updateSelectedPlayersCounts();
- // Merge names into players list; preserve colors from previous streams
- const existing = new Map();
- 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 {}
+
+ } finally {
+ loading.value = false;
+ }
};
esActions.value.onerror = () => {};
}
@@ -818,11 +836,17 @@ onMounted(() => {
// Set initial container width and add resize listener
updateContainerWidth();
window.addEventListener('resize', updateContainerWidth);
+ initDefaultRange();
+ // Start live-end timer by default if enabled
+ if (liveEnd.value) {
+ toggleLiveEnd(); // sets up the interval and applies
+ }
});
onUnmounted(() => {
closeStreams();
window.removeEventListener('resize', updateContainerWidth);
+ try { if (liveTimer) clearInterval(liveTimer); } catch {}
});
// Reset to first page when search changes or players list length shrinks below current page
@@ -832,7 +856,8 @@ 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 }[]>([]);
+// Deprecated: previously used to show a totals list; now unused
+// const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]);
function downloadJSON() {
@@ -947,6 +972,8 @@ function downloadJSON() {
.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); }
+.key.round { background: linear-gradient(90deg, #06b6d4, #0891b2); box-shadow: 0 0 8px rgba(8,145,178,0.35); }
+.key.game { background: linear-gradient(90deg, #ec4899, #8b5cf6); box-shadow: 0 0 8px rgba(236,72,153,0.35); }
.sep { opacity: 0.6; }
.player-chips, .room-chips {
@@ -1019,6 +1046,7 @@ function downloadJSON() {
flex-wrap: wrap;
align-items: center;
}
+.secondary-filters { margin: 6px 0 4px; }
.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); }
@@ -1106,6 +1134,25 @@ function downloadJSON() {
text-align: center;
}
+/* Raw payload styles */
+.raw-viewer { margin-top: 14px; padding: 12px; border-radius: 14px; }
+.raw-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
+.raw-title { font-weight: 800; color: #334155; font-size: 14px; }
+.raw-actions { display: flex; gap: 8px; }
+.raw-pre { max-height: 420px; overflow: auto; background: #0b1020; color: #e5e7eb; padding: 12px; border-radius: 10px; font-size: 12px; line-height: 1.4; border: 1px solid #1f2937; }
+
+/* Time selector styles */
+.time-selector { display: flex; flex-wrap: wrap; gap: 12px; padding: 10px; margin-bottom: 12px; align-items: center; }
+.mode-buttons { display: flex; gap: 6px; }
+.mode-btn { padding: 6px 10px; border-radius: 8px; border: 1px solid #cbd5e1; background: #fff; color: #334155; font-weight: 800; font-size: 12px; cursor: pointer; }
+.mode-btn.active { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-color: #667eea; }
+.range-inputs { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.range-inputs.disabled { opacity: 0.6; filter: grayscale(0.1); }
+.range-inputs label { display: flex; gap: 6px; align-items: center; font-weight: 700; color: #334155; }
+.range-inputs input[type="datetime-local"] { padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 12px; }
+.live-btn { padding: 6px 10px; border-radius: 8px; border: 1px solid #cbd5e1; background: #fff; color: #334155; font-weight: 800; font-size: 12px; cursor: pointer; }
+.live-btn.active { background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 100%); color: #fff; border-color: #06b6d4; }
+
/* Filters section styles */
.filters-section {
diff --git a/server/src/adminApi.ts b/server/src/adminApi.ts
index 19d0168..5368e50 100644
--- a/server/src/adminApi.ts
+++ b/server/src/adminApi.ts
@@ -909,6 +909,7 @@ async function sendPlayersActionsUpdate(client?: Response) {
const round = (entry as any)?.round;
const gameVariant = (entry as any)?.gameVariant || (entry as any)?.variant;
const role = (entry as any)?.role;
+ const timestamp = (entry as any)?.timestamp;
if (!ACTION_EVENTS.includes(kind)) continue;
if (!isActionMade(kind, role)) continue;
@@ -919,7 +920,8 @@ async function sendPlayersActionsUpdate(client?: Response) {
kind,
round,
gameVariant,
- roomId
+ roomId,
+ timestamp
});
}
@@ -947,7 +949,136 @@ async function sendPlayersActionsUpdate(client?: Response) {
};
}).filter((p: any) => p.total > 0 || p.name);
- const payload = { players };
+ // Build aggregated data across all players
+ const aggregatedCounts: Record = Object.fromEntries(ACTION_EVENTS.map(k => [k, 0])) as any;
+ const aggregatedDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string; playerUuid?: string; timestamp?: number }>[] = [] as any;
+
+ const roomsIndex: Record = {};
+ let idxCounter = 0;
+ players.forEach((p: any) => {
+ // Sum counts
+ ACTION_EVENTS.forEach(k => {
+ const c = Number(p?.counts?.[k] || 0);
+ aggregatedCounts[k] = (aggregatedCounts[k] || 0) + c;
+ });
+ // Flatten detailed history and build rooms index
+ (Array.isArray(p?.detailedHistory) ? p.detailedHistory : []).forEach((ev: any) => {
+ const e = {
+ kind: String(ev?.kind || ''),
+ round: ev?.round != null ? Number(ev.round) : undefined,
+ gameVariant: (ev?.gameVariant || ev?.variant) ? String(ev.gameVariant || ev.variant) : undefined,
+ roomId: ev?.roomId ? String(ev.roomId) : undefined,
+ playerUuid: p.uuid,
+ timestamp: ev?.timestamp != null ? Number(ev.timestamp) : undefined
+ };
+ aggregatedDetailedEvents.push(e as any);
+ const rid = e.roomId || '';
+ if (rid) {
+ if (!roomsIndex[rid]) {
+ roomsIndex[rid] = { roomId: rid, lastSeenIndex: idxCounter, messageCount: 0 };
+ }
+ roomsIndex[rid].lastSeenIndex = idxCounter; // newer index wins
+ roomsIndex[rid].messageCount += 1;
+ if (e.timestamp) {
+ roomsIndex[rid].lastEventAt = Math.max(roomsIndex[rid].lastEventAt || 0, e.timestamp);
+ }
+ idxCounter += 1;
+ }
+ });
+ });
+
+ // Optionally include snapshot of active rooms similar to /dashboard-stream
+ let activeRoomsPayload: any | undefined = undefined;
+ try {
+ const rooms = await matchMaker.query({});
+ const activeRoomDetails: any[] = [];
+ const activeCounts: Record = Object.fromEntries(ACTION_EVENTS.map(k => [k, 0]));
+ for (const room of rooms) {
+ if (room.name === 'game') {
+ try {
+ const detailData: any = await matchMaker.remoteRoomCall(room.roomId, "getState");
+ // Map systemMessages to detailed events
+ const msgs = Array.isArray(detailData?.systemMessages) ? detailData.systemMessages : [];
+ const systemMessages = msgs.map((m: any) => ({
+ kind: String(m?.kind || ''),
+ round: m?.round != null ? Number(m.round) : undefined,
+ gameVariant: (m?.gameVariant || m?.variant) ? String(m.gameVariant || m.variant) : undefined,
+ roomId: String(m?.roomId || room.roomId),
+ timestamp: m?.timestamp != null ? Number(m.timestamp) : undefined
+ }));
+ // Count events present in ACTION_EVENTS
+ systemMessages.forEach((m: any) => {
+ if (ACTION_EVENTS.includes(m.kind)) {
+ activeCounts[m.kind] = (activeCounts[m.kind] || 0) + 1;
+ }
+ });
+ activeRoomDetails.push({
+ roomId: room.roomId,
+ name: room.name,
+ metadata: {
+ gameStatus: room.metadata?.gameStatus || 'unknown',
+ currentVariant: room.metadata?.currentVariant || detailData?.variant || undefined,
+ currentRound: room.metadata?.currentRound || detailData?.round || undefined
+ },
+ players: (Array.isArray(detailData?.players) ? detailData.players : []).map((p: any) => ({
+ uuid: String(p?.uuid || p?.sessionId || ''),
+ name: p?.name || null,
+ color: p?.color || null
+ })),
+ systemMessages
+ });
+ } catch (error) {
+ // Ignore errors per room to not break the stream
+ }
+ }
+ }
+ activeRoomsPayload = {
+ rooms: activeRoomDetails,
+ counts: activeCounts
+ };
+ } catch (e) {
+ // If querying active rooms fails, omit activeRooms in payload
+ activeRoomsPayload = undefined;
+ }
+
+ // Build summary metrics
+ const playersWithNames = players.filter((p: any) => !!p.name).length;
+ let totalP1 = 0, cntP1 = 0;
+ let totalP2 = 0, cntP2 = 0;
+ players.forEach((p: any) => {
+ (Array.isArray(p?.roomScoreHistory) ? p.roomScoreHistory : []).forEach((rs: any) => {
+ (Array.isArray(rs?.scores) ? rs.scores : []).forEach((s: any) => {
+ if (s?.role === 'P1') { totalP1 += Number(s.score || 0); cntP1 += 1; }
+ else if (s?.role === 'P2') { totalP2 += Number(s.score || 0); cntP2 += 1; }
+ });
+ });
+ });
+ const avgP1 = cntP1 > 0 ? totalP1 / cntP1 : 0;
+ const avgP2 = cntP2 > 0 ? totalP2 / cntP2 : 0;
+ const overallCnt = cntP1 + cntP2;
+ const overallAvg = overallCnt > 0 ? (totalP1 + totalP2) / overallCnt : 0;
+
+ const payload = {
+ version: '1.0',
+ timestamp: Date.now(),
+ players,
+ aggregated: {
+ detailedEvents: aggregatedDetailedEvents,
+ counts: aggregatedCounts,
+ rooms: Object.values(roomsIndex)
+ },
+ activeRooms: activeRoomsPayload,
+ summary: {
+ totalPlayers: playersWithNames,
+ playersWithShame: players.filter((p: any) => Number(p?.shameTokens || 0) > 0).length,
+ playersWithoutShame: Math.max(0, playersWithNames - players.filter((p: any) => Number(p?.shameTokens || 0) > 0).length),
+ averageScore: {
+ p1: Number(avgP1.toFixed(2)),
+ p2: Number(avgP2.toFixed(2)),
+ overall: Number(overallAvg.toFixed(2))
+ }
+ }
+ };
const message = `data: ${JSON.stringify(payload)}\n\n`;
if (client) {
if (!(client as any).destroyed) client.write(message);