filter component ahora toma los datos raw, los procesa, los entrega y entrega el filtro usado

This commit is contained in:
2025-08-29 12:43:53 -06:00
parent f170a522c9
commit 21adaf4caa
3 changed files with 956 additions and 631 deletions

View File

@@ -1,123 +0,0 @@
<template>
<div class="data-source-selector glass light">
<div class="source-label">Fuente de datos:</div>
<div class="source-buttons">
<button
class="source-btn"
:class="{ active: modelValue === 'aggregated' }"
@click="$emit('update:modelValue', 'aggregated')"
title="Muestra el total histórico de todas las acciones registradas de los jugadores"
>
<span class="source-icon">📁</span>
Datos Agregados (Histórico)
</button>
<button
class="source-btn"
:class="{ active: modelValue === 'active-rooms' }"
@click="$emit('update:modelValue', 'active-rooms')"
title="Muestra solo los datos de las salas actualmente activas"
>
<span class="source-icon">🔴</span>
Salas Activas (Tiempo Real)
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { DataSource } from '../composables/useEventFilters';
interface Props {
modelValue: DataSource;
}
defineProps<Props>();
defineEmits<{
'update:modelValue': [value: DataSource];
}>();
</script>
<style scoped>
.glass.light {
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(229, 231, 235, 0.9);
box-shadow: 0 18px 50px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.6);
backdrop-filter: blur(18px) saturate(120%);
-webkit-backdrop-filter: blur(18px) saturate(120%);
border-radius: 16px;
}
.data-source-selector {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
margin-bottom: 14px;
}
.source-label {
font-size: 14px;
font-weight: 700;
color: #334155;
}
.source-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.source-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: rgba(255,255,255,0.6);
color: #64748b;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.3s ease;
}
.source-btn: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);
}
.source-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #667eea;
box-shadow: 0 6px 20px rgba(102,126,234,0.3);
}
.source-btn.active:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(102,126,234,0.4);
}
.source-icon {
font-size: 16px;
}
@media (max-width: 640px) {
.data-source-selector {
flex-direction: column;
align-items: stretch;
}
.source-buttons {
flex-direction: column;
}
.source-btn {
justify-content: center;
}
}
</style>

View File

@@ -1,12 +1,74 @@
<template>
<div class="filters-wrapper">
<!-- Time selector -->
<div class="time-selector">
<div class="mode-buttons">
<button
class="mode-btn"
:class="{ active: filters.timeMode === 'active' }"
@click="updateTimeMode('active')"
title="Mostrar solo salas activas (tiempo real)"
>
🔴 Salas activas
</button>
<button
class="mode-btn"
:class="{ active: filters.timeMode === 'range' }"
@click="updateTimeMode('range')"
title="Filtrar por rango de fecha y hora"
>
📅 Rango de tiempo
</button>
</div>
<div class="range-inputs" :class="{ disabled: filters.timeMode !== 'range' }">
<label>
Desde
<input
type="datetime-local"
:value="filters.rangeFrom"
:disabled="filters.timeMode !== 'range'"
@change="updateRangeFrom(($event.target as HTMLInputElement).value)"
/>
</label>
<label>
Hasta
<input
type="datetime-local"
:value="filters.rangeTo"
:disabled="filters.timeMode !== 'range' || filters.liveEnd"
@change="updateRangeTo(($event.target as HTMLInputElement).value)"
/>
</label>
</div>
<div class="quick">
<button
class="qs-btn live-btn"
:class="{ active: filters.liveEnd }"
:disabled="filters.timeMode !== 'range'"
@click="toggleLiveEnd"
title="Hasta ahora"
>
</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(10, 'm')">10m</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'h')">1h</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'd')">1D</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'mo')">1M</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'y')">1Y</button>
</div>
</div>
<hr class="divider" />
<!-- Round and Game filters -->
<div class="filters-container" :class="{ compact }">
<div class="filter-group">
<label class="filter-label">Ronda:</label>
<div class="filter-buttons">
<button
class="filter-btn"
:class="{ active: (roundFilter?.length||0) === 0 }"
@click="$emit('update:roundFilter', [])"
:class="{ active: filters.rounds.length === 0 }"
@click="updateRounds([])"
title="Mostrar todas las rondas"
>
Todas
@@ -15,9 +77,9 @@
v-for="r in [1, 2, 3]"
:key="r"
class="filter-btn"
:class="{ active: roundFilter?.includes(r) }"
@click="$emit('update:roundFilter', roundFilter?.includes(r) ? roundFilter.filter(x=>x!==r) : [...(roundFilter||[]), r])"
:title="`Mostrar solo Ronda ${r}`"
:class="{ active: filters.rounds.includes(r) }"
@click="toggleRound(r)"
:title="`Mostrar ronda ${r}`"
>
R{{ r }}
</button>
@@ -29,8 +91,8 @@
<div class="filter-buttons">
<button
class="filter-btn"
:class="{ active: (gameFilter?.length||0) === 0 }"
@click="$emit('update:gameFilter', [])"
:class="{ active: filters.games.length === 0 }"
@click="updateGames([])"
title="Mostrar todas las variantes"
>
Todas
@@ -39,50 +101,442 @@
v-for="g in ['G1', 'G2', 'G3', 'G4', 'G5']"
:key="g"
class="filter-btn"
:class="{ active: gameFilter?.includes(g) }"
@click="$emit('update:gameFilter', gameFilter?.includes(g) ? gameFilter.filter(x=>x!==g) : [...(gameFilter||[]), g])"
:title="`Mostrar solo variante ${g}`"
:class="{ active: filters.games.includes(g) }"
@click="toggleGame(g)"
:title="`Mostrar variante ${g}`"
>
{{ g }}
</button>
</div>
</div>
</div>
<div v-if="hasActiveFilters" class="filter-summary">
<span class="summary-icon">🔍</span>
<span class="summary-text">{{ filterSummary }}</span>
<hr class="divider" />
<!-- Player selection -->
<div class="player-chips compact">
<div class="search-controls">
<input
v-model="playerSearch"
class="search"
placeholder="Buscar jugador…"
/>
<div class="pagination compact" v-if="playerPageCount > 1">
<button class="pg-btn compact" @click="playerPage--" :disabled="playerPage <= 1"></button>
<span class="pg-ind">{{ playerPage }}/{{ playerPageCount }}</span>
<button class="pg-btn compact" @click="playerPage++" :disabled="playerPage >= playerPageCount"></button>
</div>
</div>
<div class="chips">
<button
class="reset-btn"
@click="$emit('resetFilters')"
title="Quitar todos los filtros"
v-for="p in playersPage"
:key="p.uuid"
class="chip"
:class="{ active: filters.playerUuids.includes(p.uuid) }"
@click="togglePlayer(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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { RoundFilterMulti, GameFilterMulti } from '../composables/useEventFilters';
import { ref, computed, watch, watchEffect } from 'vue';
type TimeMode = 'active' | 'range';
interface FilterState {
timeMode: TimeMode;
rangeFrom: string;
rangeTo: string;
liveEnd: boolean;
rounds: number[];
games: string[];
playerUuids: string[];
rooms: string[];
}
interface PlayerData {
uuid: string;
name: string;
color?: string;
total?: number;
shameTokens?: number;
counts?: Record<string, number>;
roomScoreHistory?: Array<{
roomId: string;
scores: Array<{
round: number;
variant: string;
role: string;
score: number;
}>;
}>;
}
interface Props {
roundFilter: RoundFilterMulti;
gameFilter: GameFilterMulti;
hasActiveFilters: boolean;
filterSummary: string;
modelValue: FilterState;
rawData?: {
players?: PlayerData[];
activeRooms?: any;
aggregatedEvents?: any[];
};
compact?: boolean;
}
withDefaults(defineProps<Props>(), { compact: false });
const props = withDefaults(defineProps<Props>(), {
compact: false
});
defineEmits<{
'update:roundFilter': [value: RoundFilterMulti];
'update:gameFilter': [value: GameFilterMulti];
'resetFilters': [];
const emit = defineEmits<{
'update:modelValue': [value: FilterState];
'filtered': [data: any];
}>();
// Local reactive copy of filters
const filters = ref<FilterState>({
timeMode: 'range',
rangeFrom: '',
rangeTo: '',
liveEnd: true,
rounds: [],
games: [],
playerUuids: [],
rooms: []
});
// Sync with v-model
watch(() => props.modelValue, (newVal) => {
filters.value = { ...newVal };
}, { immediate: true, deep: true });
// Player search and pagination
const playerSearch = ref('');
const playerPage = ref(1);
const pageSize = 15;
// Initialize default time range
function initDefaultRange() {
const now = new Date();
const from = new Date(now.getTime() - 60 * 60 * 1000); // 1h ago
filters.value.rangeFrom = formatLocal(from);
filters.value.rangeTo = formatLocal(now);
emitUpdate();
}
function formatLocal(dt: Date): string {
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}`;
}
// Players list from raw data
const players = computed(() => {
if (!props.rawData?.players) return [];
return props.rawData.players.filter(p => p.name);
});
const playersFiltered = computed(() => {
const q = playerSearch.value.toLowerCase();
if (!q) return players.value;
return players.value.filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.uuid || '').toLowerCase().includes(q)
);
});
const playerPageCount = computed(() =>
Math.ceil(playersFiltered.value.length / pageSize)
);
const playersPage = computed(() => {
const start = (playerPage.value - 1) * pageSize;
return playersFiltered.value.slice(start, start + pageSize);
});
// Reset page when search changes
watch(playerSearch, () => {
playerPage.value = 1;
});
// Helper to get initials
function initials(name: string): string {
if (!name) return '?';
const parts = name.split(/\s+/);
if (parts.length === 1) return name.slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
// Update functions that emit changes
function emitUpdate() {
emit('update:modelValue', { ...filters.value });
}
function updateTimeMode(mode: TimeMode) {
filters.value.timeMode = mode;
emitUpdate();
applyFilters();
}
function updateRangeFrom(value: string) {
filters.value.rangeFrom = value;
emitUpdate();
applyFilters();
}
function updateRangeTo(value: string) {
filters.value.rangeTo = value;
emitUpdate();
applyFilters();
}
function toggleLiveEnd() {
filters.value.liveEnd = !filters.value.liveEnd;
if (filters.value.liveEnd) {
filters.value.rangeTo = formatLocal(new Date());
}
emitUpdate();
applyFilters();
}
function incrementFrom(n: number, unit: 'm' | 'h' | 'd' | 'mo' | 'y') {
const to = filters.value.liveEnd ? new Date() : new Date(Date.parse(filters.value.rangeTo || ''));
if (Number.isNaN(to.getTime())) {
to.setTime(Date.now());
}
let from = new Date(Date.parse(filters.value.rangeFrom || ''));
if (Number.isNaN(from.getTime())) {
from = new Date(to.getTime());
}
// Move 'from' backwards in time
if (unit === 'm') from.setMinutes(from.getMinutes() - n);
else if (unit === 'h') from.setHours(from.getHours() - n);
else if (unit === 'd') from.setDate(from.getDate() - n);
else if (unit === 'mo') from.setMonth(from.getMonth() - n);
else if (unit === 'y') from.setFullYear(from.getFullYear() - n);
// Ensure minimum window
const minWindowMs = 60 * 1000;
if (to.getTime() - from.getTime() < minWindowMs) {
from = new Date(to.getTime() - minWindowMs);
}
filters.value.rangeFrom = formatLocal(from);
filters.value.rangeTo = formatLocal(to);
emitUpdate();
applyFilters();
}
function updateRounds(rounds: number[]) {
filters.value.rounds = rounds;
emitUpdate();
applyFilters();
}
function toggleRound(round: number) {
const idx = filters.value.rounds.indexOf(round);
if (idx >= 0) {
filters.value.rounds.splice(idx, 1);
} else {
filters.value.rounds.push(round);
}
emitUpdate();
applyFilters();
}
function updateGames(games: string[]) {
filters.value.games = games;
emitUpdate();
applyFilters();
}
function toggleGame(game: string) {
const idx = filters.value.games.indexOf(game);
if (idx >= 0) {
filters.value.games.splice(idx, 1);
} else {
filters.value.games.push(game);
}
emitUpdate();
applyFilters();
}
function togglePlayer(uuid: string) {
const idx = filters.value.playerUuids.indexOf(uuid);
if (idx >= 0) {
filters.value.playerUuids.splice(idx, 1);
} else {
filters.value.playerUuids.push(uuid);
}
emitUpdate();
applyFilters();
}
// Live update timer
let liveTimer: any = null;
watchEffect(() => {
if (filters.value.liveEnd && filters.value.timeMode === 'range') {
if (liveTimer) clearInterval(liveTimer);
liveTimer = setInterval(() => {
filters.value.rangeTo = formatLocal(new Date());
emitUpdate();
applyFilters();
}, 15000); // Update every 15 seconds
} else {
if (liveTimer) {
clearInterval(liveTimer);
liveTimer = null;
}
}
});
// Apply filters to raw data and emit filtered result
function applyFilters() {
if (!props.rawData) {
emit('filtered', null);
return;
}
const result: any = {
players: [],
events: [],
metrics: {},
aggregatedCounts: {}
};
// Filter based on time mode
if (filters.value.timeMode === 'active') {
// Use active rooms data
result.activeRooms = props.rawData.activeRooms;
// TODO: Extract events from active rooms
} else {
// Filter by time range
const fromMs = Date.parse(filters.value.rangeFrom || '');
const toMs = Date.parse(filters.value.rangeTo || '');
if (props.rawData.aggregatedEvents) {
result.events = props.rawData.aggregatedEvents.filter((ev: any) => {
// Time filter
if (!Number.isNaN(fromMs) && !Number.isNaN(toMs)) {
const t = ev.timestamp;
if (typeof t === 'number' && (t < fromMs || t > toMs)) return false;
}
// Round filter
if (filters.value.rounds.length > 0) {
if (!filters.value.rounds.includes(ev.round)) return false;
}
// Game filter
if (filters.value.games.length > 0) {
if (!filters.value.games.includes(ev.gameVariant)) return false;
}
// Room filter
if (filters.value.rooms.length > 0) {
if (!filters.value.rooms.includes(ev.roomId)) return false;
}
return true;
});
}
}
// Filter players
if (props.rawData.players) {
result.players = props.rawData.players.filter((p: PlayerData) => {
if (filters.value.playerUuids.length > 0) {
return filters.value.playerUuids.includes(p.uuid);
}
return true;
});
// Calculate metrics from filtered players
let totalP1Scores = 0;
let totalP2Scores = 0;
let p1Count = 0;
let p2Count = 0;
let playersWithShame = 0;
result.players.forEach((player: PlayerData) => {
if (player.shameTokens && player.shameTokens > 0) {
playersWithShame++;
}
if (player.roomScoreHistory) {
player.roomScoreHistory.forEach(roomScore => {
roomScore.scores.forEach(score => {
// Apply filters to scores
if (filters.value.rounds.length > 0 && !filters.value.rounds.includes(score.round)) return;
if (filters.value.games.length > 0 && !filters.value.games.includes(score.variant)) return;
if (filters.value.rooms.length > 0 && !filters.value.rooms.includes(roomScore.roomId)) return;
if (score.role === 'P1') {
totalP1Scores += score.score;
p1Count++;
} else if (score.role === 'P2') {
totalP2Scores += score.score;
p2Count++;
}
});
});
}
});
result.metrics = {
players_seated: result.players.length,
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: playersWithShame,
players_without_shame: result.players.length - playersWithShame
};
}
// Count events by type
const eventTypes = [
'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'
];
result.aggregatedCounts = {};
eventTypes.forEach(type => {
result.aggregatedCounts[type] = result.events.filter((e: any) => e.kind === type).length;
});
emit('filtered', result);
}
// Initialize on mount
if (!filters.value.rangeFrom) {
initDefaultRange();
}
// Apply filters whenever raw data changes
watch(() => props.rawData, () => {
applyFilters();
}, { deep: true });
</script>
<style scoped>
.filters-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
.filters-container {
display: flex;
gap: 12px;
@@ -102,7 +556,12 @@ defineEmits<{
gap: 12px;
}
.filter-label { font-size: 13px; font-weight: 700; color: #334155; min-width: 52px; }
.filter-label {
font-size: 13px;
font-weight: 700;
color: #334155;
min-width: 52px;
}
.filter-buttons {
display: flex;
@@ -142,17 +601,273 @@ defineEmits<{
box-shadow: 0 6px 20px rgba(102,126,234,0.4);
}
.filter-summary { display: none; }
/* Time selector styles */
.time-selector {
align-items: center;
}
.summary-icon {
.mode-buttons {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.mode-btn {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
font-weight: 800;
font-size: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.mode-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.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;
margin-bottom: 10px;
}
.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;
font-size: 13px;
}
.range-inputs input[type="datetime-local"] {
padding: 6px 8px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 12px;
background: #fff;
color: #0f172a;
}
/* Quick select buttons */
.quick {
display: flex;
gap: 6px;
}
.qs-btn {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(148,163,184,0.35);
background: rgba(255,255,255,0.6);
color: #475569;
font-size: 11px;
font-weight: 800;
cursor: pointer;
opacity: 0.85;
transition: all 0.2s ease;
}
.qs-btn:hover:not(:disabled) {
opacity: 1;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.qs-btn:active {
transform: translateY(0);
}
.qs-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.live-btn.active {
background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 100%);
color: #fff;
border-color: #06b6d4;
opacity: 1;
}
.divider {
border: 0;
border-top: 1px solid rgba(203,213,225,0.6);
margin: 6px 0;
}
/* Player chips styles */
.player-chips {
display: flex;
flex-direction: column;
gap: 12px;
}
.player-chips.compact .search {
padding: 6px 8px;
min-width: 180px;
font-size: 12px;
}
.player-chips.compact .chip {
padding: 6px 10px;
font-size: 12px;
}
.player-chips.compact .avatar {
width: 20px;
height: 20px;
font-size: 12px;
}
.search-controls {
display: flex;
align-items: center;
gap: 12px;
justify-content: start;
flex-wrap: wrap;
}
.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);
}
.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 */
.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);
}
.summary-text {
white-space: nowrap;
.pg-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(102,126,234,0.35);
}
.reset-btn { display: none; }
.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;
}
@media (max-width: 768px) {
.filters-container {
@@ -169,8 +884,13 @@ defineEmits<{
justify-content: flex-end;
}
.filter-summary {
justify-content: center;
.time-selector {
padding: 8px;
}
.range-inputs {
flex-direction: column;
align-items: stretch;
}
}
@@ -189,5 +909,19 @@ defineEmits<{
.filter-buttons {
justify-content: center;
}
.mode-buttons {
flex-direction: column;
gap: 8px;
}
.mode-btn {
width: 100%;
}
.quick {
justify-content: center;
flex-wrap: wrap;
}
}
</style>

View File

@@ -22,22 +22,22 @@
<span class="marker-chip">
<span class="key time"></span>
{{ periodLabel }}
<button class="chip-x" v-if="timeMode == 'range'" @click="clearTimeFilter" title="Borrar filtro de tiempo">×</button>
<button class="chip-x" v-if="filterState.timeMode == 'range'" @click="clearTimeFilter" title="Borrar filtro de tiempo">×</button>
</span>
<span class="marker-chip" v-if="roundActive">
<span class="marker-chip" v-if="filterState.rounds.length">
<span class="key round"></span>
{{ roundLabel }}
<button class="chip-x" @click="eventFilters.roundFilter.value = []; onRoundGameChange()">×</button>
Ronda: {{ filterState.rounds.join(',') }}
<button class="chip-x" @click="filterState.rounds = []">×</button>
</span>
<span class="marker-chip" v-if="gameActive">
<span class="marker-chip" v-if="filterState.games.length">
<span class="key game"></span>
{{ gameLabel }}
<button class="chip-x" @click="eventFilters.gameFilter.value = []; onRoundGameChange()">×</button>
Juego: {{ filterState.games.join(',') }}
<button class="chip-x" @click="filterState.games = []">×</button>
</span>
<span class="marker-chip" v-if="selectedUuids.length" >
<span class="marker-chip" v-if="filterState.playerUuids.length" >
<span class="key player"></span>
Jugadores: {{ selectedUuids.length }}
<button class="chip-x" @click="clearPlayers(); onRoundGameChange()" title="Quitar selección de jugadores">×</button>
Jugadores: {{ filterState.playerUuids.length }}
<button class="chip-x" @click="filterState.playerUuids = []" title="Quitar selección de jugadores">×</button>
</span>
</div>
</div>
@@ -47,84 +47,17 @@
<Transition name="filters-slide">
<div v-if="!filtersCollapsed" class="controls glass light">
<!-- Selector de periodo: Salas activas vs Rango de tiempo -->
<div class="time-selector">
<div class="mode-buttons">
<button
class="mode-btn"
:class="{ active: timeMode === 'active' }"
@click="setTimeMode('active')"
title="Mostrar solo salas activas (tiempo real)"
>
🔴 Salas activas
</button>
<button
class="mode-btn"
:class="{ active: timeMode === 'range' }"
@click="setTimeMode('range')"
title="Filtrar por rango de fecha y hora"
>
📅 Rango de tiempo
</button>
</div>
<div class="range-inputs" :class="{ disabled: timeMode !== 'range' }">
<label>
Desde
<input type="datetime-local" v-model="rangeFromStr" :disabled="timeMode !== 'range'" @change="applyTimeMode" />
</label>
<label>
Hasta
<input type="datetime-local" v-model="rangeToStr" :disabled="timeMode !== 'range' || liveEnd" @change="applyTimeMode" />
</label>
</div>
<div class="quick">
<button class="qs-btn live-btn" :class="{ active: liveEnd }" :disabled="timeMode !== 'range'" @click="toggleLiveEnd" title="Hasta ahora"></button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="incrementFrom(10,'m')">10m</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="incrementFrom(1,'h')">1h</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="incrementFrom(1,'d')">1D</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="incrementFrom(1,'mo')">1M</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="incrementFrom(1,'y')">1Y</button>
</div>
</div>
<hr class="divider" />
<!-- Ronda / Juego -->
<!-- EventFilters component with v-model -->
<EventFilters
:round-filter="eventFilters.roundFilter.value"
:game-filter="eventFilters.gameFilter.value"
:has-active-filters="false"
:filter-summary="''"
v-model="filterState"
:raw-data="{
players: allPlayersWithScores,
aggregatedEvents: fullAggregatedEvents,
activeRooms: rawActionsPayload?.activeRooms
}"
:compact="true"
@update:round-filter="eventFilters.roundFilter.value = $event; onRoundGameChange()"
@update:game-filter="eventFilters.gameFilter.value = $event; onRoundGameChange()"
@reset-filters="eventFilters.resetFilters"
@filtered="onFiltered"
/>
<hr class="divider" />
<div class="player-chips compact">
<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: selectedUuids.includes(p.uuid) }"
@click="togglePlayer(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>
</div>
</div>
<!-- Room slice UI removida: reemplazada por rango de tiempo/activas -->
</div>
@@ -137,7 +70,7 @@
:event-styles="EVENT_STYLES"
:global-event-counts="combinedGlobalCounts"
:player-event-counts="combinedPlayerCounts"
:selected-player-uuid="selectedUuids.length ? selectedUuids[0] : ''"
:selected-player-uuid="filterState.playerUuids.length ? filterState.playerUuids[0] : ''"
:player-bar-gradient="playerBarGradient"
view-mode="ratio"
:loading="loading"
@@ -165,6 +98,18 @@
</div>
<pre v-show="showRaw" class="raw-pre">{{ prettyRaw }}</pre>
</div>
<!-- Datos filtrados (vista actual) -->
<div class="raw-viewer glass light">
<div class="raw-header">
<div class="raw-title">Datos filtrados (vista actual)</div>
<div class="raw-actions">
<button class="btn" @click="showFiltered = !showFiltered">{{ showFiltered ? 'Ocultar' : 'Mostrar' }}</button>
<button class="btn" @click="copyFiltered" title="Copiar JSON">Copiar</button>
</div>
</div>
<pre v-show="showFiltered" class="raw-pre">{{ prettyFiltered }}</pre>
</div>
<AppCredits position="bottom-right" />
</div>
</template>
@@ -184,103 +129,36 @@ const loading = ref(false);
const eventFilters = useEventFilters();
const filtersCollapsed = ref(false);
// Time mode and range handling
type TimeMode = 'active' | 'range';
const timeMode = ref<TimeMode>('range');
const rangeFromStr = ref<string>('');
const rangeToStr = ref<string>('');
const liveEnd = ref<boolean>(true);
let liveTimer: any = null;
// Filter state using new EventFilters approach
const filterState = ref({
timeMode: 'range' as 'active' | 'range',
rangeFrom: '',
rangeTo: '',
liveEnd: true,
rounds: [] as number[],
games: [] as string[],
playerUuids: [] as string[],
rooms: [] as string[]
});
const fullAggregatedEvents = ref<Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string; playerUuid?: string; timestamp?: number }>>([]);
const filteredData = ref<any>(null);
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<string, number> = 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<string, number> = {};
const ids = new Set<string>();
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);
}
// Clear time filter - switch to active rooms mode
function clearTimeFilter() {
// Switch to active rooms and set a 1-minute window ending now for consistency
timeMode.value = 'active';
const now = new Date();
rangeToStr.value = fmtLocal(now);
const from = new Date(now.getTime() - 60 * 1000);
rangeFromStr.value = fmtLocal(from);
liveEnd.value = true;
applyTimeMode();
filterState.value.timeMode = 'active';
}
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; }
// Handle filtered data from EventFilters component
function onFiltered(data: any) {
filteredData.value = data;
if (data) {
// Update global counts from filtered data
eventFilters.globalEventCounts.value = data.aggregatedCounts || {};
// Update metrics
additionalMetrics.value = data.metrics || {};
// Recompute selected player metrics
computeSelectedPlayersMetrics();
}
}
@@ -345,15 +223,15 @@ const allPlayersWithScores = ref<any[]>([]);
// Function to check if a score passes the current filters
function scorePassesFilters(score: any, roomId: string) {
// Room filter (array): empty means all
const rf = eventFilters.roomFilter.value;
const rf = filterState.value.rooms;
if (rf.length && !rf.includes(String(roomId))) return false;
// Round filter (array)
const rds = eventFilters.roundFilter.value;
const rds = filterState.value.rounds;
if (rds.length && !rds.includes(Number(score.round))) return false;
// Game variant filter (array)
const gfs = eventFilters.gameFilter.value;
const gfs = filterState.value.games;
if (gfs.length && !gfs.includes(String(score.variant))) return false;
return true;
@@ -416,7 +294,7 @@ function computeMetricsFromScores() {
// Function to compute metrics for selected players (multi-select)
function computeSelectedPlayersMetrics() {
const uuids = selectedUuids.value;
const uuids = filterState.value.playerUuids;
if (!uuids.length) {
selectedPlayerMetrics.value = {
players_seated: 0,
@@ -534,122 +412,25 @@ const totalPlayersCount = computed(() => {
// Active filters object
const activeFilters = computed(() => ({
dataSource: eventFilters.dataSource.value,
round: eventFilters.roundFilter.value.join(',') || 'all',
game: eventFilters.gameFilter.value.join(',') || 'all',
hasFilters: eventFilters.hasActiveFilters.value || selectedRoomIds.value.length > 0,
selectedPlayer: selectedUuids.value.length ? `${selectedUuids.value.length} jugadores` : undefined,
selectedRoom: selectedRoomIds.value.length ? `${selectedRoomIds.value.length} salas` : undefined
dataSource: filterState.value.timeMode === 'active' ? 'active-rooms' : 'aggregated',
round: filterState.value.rounds.join(',') || 'all',
game: filterState.value.games.join(',') || 'all',
hasFilters: filterState.value.rounds.length > 0 || filterState.value.games.length > 0 || filterState.value.playerUuids.length > 0,
selectedPlayer: filterState.value.playerUuids.length ? `${filterState.value.playerUuids.length} jugadores` : undefined,
selectedRoom: filterState.value.rooms.length ? `${filterState.value.rooms.length} salas` : 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();
});
// Watch for changes in filter state to update selected player metrics
watch(filterState, () => {
computeSelectedPlayersMetrics();
}, { deep: true });
// selectedUuid watch will be added after selectedUuid is declared
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 = selectedUuids.value.length ? 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 = selectedRoomIds.value.length ? 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 selectedUuids = ref<string[]>([]);
const selectedRoomIds = ref<string[]>([]);
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 players changes to update their metrics
watch(selectedUuids, () => {
computeSelectedPlayersMetrics();
updateSelectedPlayersCounts();
}, { deep: true });
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 togglePlayer(uuid: string) {
const idx = selectedUuids.value.indexOf(uuid);
if (idx >= 0) selectedUuids.value.splice(idx, 1);
else selectedUuids.value.push(uuid);
updateSelectedPlayersCounts();
}
function clearPlayers() {
selectedUuids.value = [];
updateSelectedPlayersCounts();
}
@@ -657,22 +438,6 @@ 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); }
// 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(() => '#8b5cf6');
@@ -696,59 +461,61 @@ function copyRaw() {
} catch {}
}
// Filtered snapshot (what the UI is showing after filters)
const showFiltered = ref(false);
const filteredSnapshot = computed(() => {
try {
return {
metadata: {
timestamp: new Date().toISOString(),
dataSource: filterState.value.timeMode === 'active' ? 'active-rooms' : 'aggregated',
filters: {
room: filterState.value.rooms,
round: filterState.value.rounds,
game: filterState.value.games,
selectedPlayers: filterState.value.playerUuids,
selectedRooms: selectedRoomIds.value,
timeMode: filterState.value.timeMode,
from: filterState.value.rangeFrom,
to: filterState.value.rangeTo,
liveEnd: filterState.value.liveEnd
}
},
chartTotals: {
offers: offersTotal.value,
responses: responsesTotal.value,
force: forceTotal.value,
shame: shameTotal.value,
report: reportTotal.value,
averageScore: averageScoreTotal.value,
totalPlayers: totalPlayersCount.value
},
globalEventCounts: eventFilters.globalEventCounts.value,
playerEventCounts: playerEventCounts.value,
combinedCounts: {
global: combinedGlobalCounts.value,
players: combinedPlayerCounts.value
}
};
} catch (e) {
return { error: String(e) };
}
});
const prettyFiltered = computed(() => JSON.stringify(filteredSnapshot.value, null, 2));
function copyFiltered() {
try { navigator.clipboard.writeText(prettyFiltered.value); } catch {}
}
// Indicator labels
const periodLabel = computed(() => {
if (timeMode.value === 'active') return 'Salas activas';
const from = (rangeFromStr.value || '').replace('T', ' ');
const to = (rangeToStr.value || '').replace('T', ' ');
return `${from}${to}${liveEnd.value ? ' (ahora)' : ''}`;
if (filterState.value.timeMode === 'active') return 'Salas activas';
const from = (filterState.value.rangeFrom || '').replace('T', ' ');
const to = (filterState.value.rangeTo || '').replace('T', ' ');
return `${from}${to}${filterState.value.liveEnd ? ' (ahora)' : ''}`;
});
const roundActive = computed(() => eventFilters.roundFilter.value.length > 0);
const gameActive = computed(() => eventFilters.gameFilter.value.length > 0);
const roundLabel = computed(() => `Ronda: ${eventFilters.roundFilter.value.join(',')}`);
const gameLabel = computed(() => `Juego: ${eventFilters.gameFilter.value.join(',')}`);
function onRoundGameChange() {
eventFilters.applyFilters(EVENTS);
computeMetricsFromScores();
}
function incrementFrom(n: number, unit: 'm'|'h'|'d'|'mo'|'y') {
// Ensure we are in range mode to apply manual adjustments
if (timeMode.value !== 'range') {
timeMode.value = 'range';
}
// Determine current 'to' reference (now when liveEnd is enabled)
const to = liveEnd.value ? new Date() : new Date(Date.parse(rangeToStr.value || ''));
if (Number.isNaN(to.getTime())) {
// Initialize to now if empty/invalid
to.setTime(Date.now());
}
// Parse current 'from' or initialize to a sensible default window
let from = new Date(Date.parse(rangeFromStr.value || ''));
if (Number.isNaN(from.getTime())) {
from = new Date(to.getTime());
}
// Apply incremental increase to 'from'
// Move 'Desde' backwards in time by the chosen amount (expand window)
if (unit === 'm') from.setMinutes(from.getMinutes() - n);
else if (unit === 'h') from.setHours(from.getHours() - n);
else if (unit === 'd') from.setDate(from.getDate() - n);
else if (unit === 'mo') from.setMonth(from.getMonth() - n);
else if (unit === 'y') from.setFullYear(from.getFullYear() - n);
// Clamp to keep at least 1 minute window and never exceed 'to'
const minWindowMs = 60 * 1000;
if (to.getTime() - from.getTime() < minWindowMs) {
from = new Date(to.getTime() - minWindowMs);
}
rangeFromStr.value = fmtLocal(from);
rangeToStr.value = fmtLocal(to);
applyTimeMode();
}
function closeStreams() {
try { esActions.value?.close(); } catch {}
@@ -779,15 +546,6 @@ function setupStreams() {
});
});
// 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<string, Record<string, number>> = {};
@@ -801,32 +559,10 @@ function setupStreams() {
});
playersActionsByUuid.value = byUuid;
// Datos agregados desde el payload
// Store aggregated events for the EventFilters component
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);
}
// 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;
@@ -857,14 +593,9 @@ function setupStreams() {
})).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
// Recalculate metrics from score history
computeMetricsFromScores();
// Aplicar filtros según dataSource actual
eventFilters.applyFilters(EVENTS);
// Actualizar conteos del/los jugador(es) seleccionado(s)
updateSelectedPlayersCounts();
@@ -883,7 +614,7 @@ function updateSelectedPlayersCounts() {
playerLoading.value = true;
const next: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
const byUuid = playersActionsByUuid.value;
selectedUuids.value.forEach(uuid => {
filterState.value.playerUuids.forEach(uuid => {
const counts = byUuid[uuid] || {};
EVENTS.forEach(k => { next[k] = (next[k] || 0) + Number(counts[k] || 0); });
});
@@ -891,37 +622,32 @@ function updateSelectedPlayersCounts() {
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);
initDefaultRange();
// Start live-end timer by default if enabled
if (liveEnd.value) {
toggleLiveEnd(); // sets up the interval and applies
}
// Initialize default time range for filter state
const now = new Date();
const from = new Date(now.getTime() - 60 * 60 * 1000); // 1h ago
const pad = (n: number) => String(n).padStart(2, '0');
const formatLocal = (dt: Date) => {
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}`;
};
filterState.value.rangeFrom = formatLocal(from);
filterState.value.rangeTo = formatLocal(now);
});
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
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
// Deprecated: previously used to show a totals list; now unused
@@ -933,15 +659,18 @@ function downloadJSON() {
const currentData = {
metadata: {
timestamp: new Date().toISOString(),
dataSource: eventFilters.dataSource.value,
dataSource: filterState.value.timeMode === 'active' ? 'active-rooms' : 'aggregated',
filters: {
room: eventFilters.roomFilter.value,
round: eventFilters.roundFilter.value,
game: eventFilters.gameFilter.value,
hasActiveFilters: eventFilters.hasActiveFilters.value,
filterSummary: eventFilters.filterSummary.value,
selectedPlayers: selectedUuids.value,
selectedRooms: selectedRoomIds.value
room: filterState.value.rooms,
round: filterState.value.rounds,
game: filterState.value.games,
hasActiveFilters: activeFilters.value.hasFilters,
selectedPlayers: filterState.value.playerUuids,
selectedRooms: selectedRoomIds.value,
timeMode: filterState.value.timeMode,
rangeFrom: filterState.value.rangeFrom,
rangeTo: filterState.value.rangeTo,
liveEnd: filterState.value.liveEnd
}
},
@@ -960,20 +689,20 @@ function downloadJSON() {
globalEventCounts: eventFilters.globalEventCounts.value,
// Selected player event counts (if applicable)
selectedPlayersEventCounts: selectedUuids.value.length ? playerEventCounts.value : null,
selectedPlayersEventCounts: filterState.value.playerUuids.length ? playerEventCounts.value : null,
// Additional metrics being displayed
additionalMetrics: additionalMetrics.value,
// Selected player metrics (if applicable)
selectedPlayersMetrics: selectedUuids.value.length ? selectedPlayerMetrics.value : null,
selectedPlayersMetrics: filterState.value.playerUuids.length ? selectedPlayerMetrics.value : null,
// Combined counts used in charts
combinedGlobalCounts: combinedGlobalCounts.value,
combinedPlayerCounts: selectedUuids.value.length ? combinedPlayerCounts.value : null,
combinedPlayerCounts: filterState.value.playerUuids.length ? combinedPlayerCounts.value : null,
// Players data
players: players.value,
players: allPlayersWithScores.value,
availableRooms: availableRooms.value,
// Current filtered data if available
@@ -1004,8 +733,8 @@ function downloadJSON() {
// 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 dataSourceLabel = filterState.value.timeMode === 'range' ? 'agregados' : 'activos';
const filterLabel = activeFilters.value.hasFilters ? '_filtrado' : '';
const filename = `leaderboard_${dataSourceLabel}${filterLabel}_${timestamp}.json`;
link.setAttribute('download', filename);
@@ -1075,11 +804,6 @@ function downloadJSON() {
.dual-slider .range.start { z-index: 2; }
.dual-slider .range.end { z-index: 3; }
/* Quick select buttons: super compact and subtle */
.quick-select { display: flex; justify-content: flex-end; gap: 6px; margin-top: 4px; }
.qs-btn { padding: 2px 6px; border-radius: 999px; border: 1px solid rgba(148,163,184,0.35); background: rgba(255,255,255,0.6); color: #475569; font-size: 11px; font-weight: 800; cursor: pointer; opacity: 0.85; transition: all 0.2s ease; }
.qs-btn:hover { opacity: 1; box-shadow: 0 2px 6px rgba(0,0,0,0.08); transform: translateY(-1px); }
.qs-btn:active { transform: translateY(0); }
@media (max-width: 640px) {
.dual-slider { height: 32px; padding: 14px 6px; }
@@ -1222,17 +946,7 @@ function downloadJSON() {
.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 { align-items: center; }
.mode-buttons { display: flex; gap: 6px; margin-bottom: 10px; }
.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; margin-bottom: 10px; }
.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; }
/* Time indicator key */
.key.time { background: linear-gradient(90deg, #f59e0b, #d97706); box-shadow: 0 0 8px rgba(245,158,11,0.35); }