filtros mejorados en leaderboard

This commit is contained in:
2025-08-28 03:49:37 -06:00
parent ba0f6265a7
commit 7de7263c41
5 changed files with 161 additions and 141 deletions

View File

@@ -566,6 +566,15 @@ function friendlyEventName(eventType: string): string {
text-align: center;
}
/* Place the group total just next to the title */
.ratio-card .card-header {
justify-content: flex-start;
gap: 8px;
}
.ratio-card .group-total {
margin-left: 6px;
}
.ratio-bar {
position: relative;
height: 60px;

View File

@@ -5,8 +5,8 @@
<div class="filter-buttons">
<button
class="filter-btn"
:class="{ active: roundFilter === 'all' }"
@click="$emit('update:roundFilter', 'all')"
:class="{ active: (roundFilter?.length||0) === 0 }"
@click="$emit('update:roundFilter', [])"
title="Mostrar todas las rondas"
>
Todas
@@ -15,8 +15,8 @@
v-for="r in [1, 2, 3]"
:key="r"
class="filter-btn"
:class="{ active: roundFilter === r }"
@click="$emit('update:roundFilter', r)"
:class="{ active: roundFilter?.includes(r) }"
@click="$emit('update:roundFilter', roundFilter?.includes(r) ? roundFilter.filter(x=>x!==r) : [...(roundFilter||[]), r])"
:title="`Mostrar solo Round ${r}`"
>
R{{ r }}
@@ -29,8 +29,8 @@
<div class="filter-buttons">
<button
class="filter-btn"
:class="{ active: gameFilter === 'all' }"
@click="$emit('update:gameFilter', 'all')"
:class="{ active: (gameFilter?.length||0) === 0 }"
@click="$emit('update:gameFilter', [])"
title="Mostrar todas las variantes"
>
Todas
@@ -39,8 +39,8 @@
v-for="g in ['G1', 'G2', 'G3', 'G4', 'G5']"
:key="g"
class="filter-btn"
:class="{ active: gameFilter === g }"
@click="$emit('update:gameFilter', g)"
:class="{ active: gameFilter?.includes(g) }"
@click="$emit('update:gameFilter', gameFilter?.includes(g) ? gameFilter.filter(x=>x!==g) : [...(gameFilter||[]), g])"
:title="`Mostrar solo variante ${g}`"
>
{{ g }}
@@ -63,11 +63,11 @@
</template>
<script setup lang="ts">
import type { RoundFilter, GameFilter } from '../composables/useEventFilters';
import type { RoundFilterMulti, GameFilterMulti } from '../composables/useEventFilters';
interface Props {
roundFilter: RoundFilter;
gameFilter: GameFilter;
roundFilter: RoundFilterMulti;
gameFilter: GameFilterMulti;
hasActiveFilters: boolean;
filterSummary: string;
}
@@ -75,8 +75,8 @@ interface Props {
defineProps<Props>();
defineEmits<{
'update:roundFilter': [value: RoundFilter];
'update:gameFilter': [value: GameFilter];
'update:roundFilter': [value: RoundFilterMulti];
'update:gameFilter': [value: GameFilterMulti];
'resetFilters': [];
}>();
</script>
@@ -229,4 +229,4 @@ defineEmits<{
justify-content: center;
}
}
</style>
</style>

View File

@@ -10,16 +10,16 @@ export interface DetailedEvent {
}
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 type RoundFilterMulti = number[]; // empty means all
export type GameFilterMulti = string[]; // empty means all
export type RoomFilterMulti = string[]; // empty means all
export function useEventFilters() {
// Filter states
const dataSource = ref<DataSource>('aggregated');
const roundFilter = ref<RoundFilter>('all');
const gameFilter = ref<GameFilter>('all');
const roomFilter = ref<RoomFilter>('all');
const roundFilter = ref<RoundFilterMulti>([]);
const gameFilter = ref<GameFilterMulti>([]);
const roomFilter = ref<RoomFilterMulti>([]);
// Event data stores
const detailedEventsAggregated = ref<DetailedEvent[]>([]);
@@ -38,13 +38,13 @@ export function useEventFilters() {
// Filter events based on round, game, and room
const filteredEvents = sourceEvents.filter(event => {
if (roundFilter.value !== 'all' && event.round !== roundFilter.value) {
if (roundFilter.value.length && !roundFilter.value.includes(Number(event.round))) {
return false;
}
if (gameFilter.value !== 'all' && event.gameVariant !== gameFilter.value) {
if (gameFilter.value.length && !gameFilter.value.includes(String(event.gameVariant))) {
return false;
}
if (roomFilter.value !== 'all' && event.roomId !== roomFilter.value) {
if (roomFilter.value.length && !roomFilter.value.includes(String(event.roomId))) {
return false;
}
return true;
@@ -75,9 +75,9 @@ export function useEventFilters() {
// Reset filters
function resetFilters() {
roundFilter.value = 'all';
gameFilter.value = 'all';
roomFilter.value = 'all';
roundFilter.value = [];
gameFilter.value = [];
roomFilter.value = [];
}
// Computed properties
@@ -90,14 +90,14 @@ export function useEventFilters() {
);
const hasActiveFilters = computed(() =>
roundFilter.value !== 'all' || gameFilter.value !== 'all' || roomFilter.value !== 'all'
roundFilter.value.length > 0 || gameFilter.value.length > 0 || roomFilter.value.length > 0
);
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)}`);
if (roundFilter.value.length) parts.push(`Round ${roundFilter.value.join(',')}`);
if (gameFilter.value.length) parts.push(`Game ${gameFilter.value.join(',')}`);
if (roomFilter.value.length) parts.push(`Rooms ${roomFilter.value.map(r => r.slice(0,8)).join(',')}`);
return parts.length > 0 ? parts.join(' + ') : 'Sin filtros';
});
@@ -125,4 +125,4 @@ export function useEventFilters() {
hasActiveFilters,
filterSummary
};
}
}

View File

@@ -42,9 +42,9 @@
<div class="legend">
<span class="key global"></span> Global
<span class="sep">·</span>
<span class="key player" v-if="selectedUuid"></span> Jugador
<span class="sep" v-if="selectedRoomId">·</span>
<span class="key room" v-if="selectedRoomId"></span> Sala
<span class="key player" v-if="selectedUuids.length"></span> Jugadores
<span class="sep" v-if="selectedRoomIds.length">·</span>
<span class="key room" v-if="selectedRoomIds.length"></span> Salas
</div>
<div class="player-chips">
<div class="search-controls">
@@ -60,15 +60,15 @@
v-for="p in playersPage"
:key="p.uuid"
class="chip"
:class="{ active: p.uuid === selectedUuid }"
@click="selectPlayer(p.uuid)"
: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>
<button v-if="selectedUuid" class="chip clear" @click="clearPlayer">Quitar selección</button>
<button v-if="selectedUuids.length" class="chip clear" @click="clearPlayers">Quitar selección</button>
</div>
</div>
@@ -87,15 +87,15 @@
v-for="r in roomsPage"
:key="r.roomId"
class="chip room-chip"
:class="{ active: r.roomId === selectedRoomId }"
@click="selectRoom(r.roomId)"
:class="{ active: selectedRoomIds.includes(r.roomId) }"
@click="toggleRoom(r.roomId)"
:title="`Sala: ${r.roomId} (${r.playerCount || 0} jugadores)`"
>
<span class="avatar">🏠</span>
<span class="label">{{ r.name }}</span>
<span class="count" v-if="r.playerCount">{{ r.playerCount }}</span>
</button>
<button v-if="selectedRoomId" class="chip clear" @click="clearRoom">Quitar selección</button>
<button v-if="selectedRoomIds.length" class="chip clear" @click="clearRooms">Quitar selección</button>
</div>
</div>
</div>
@@ -106,7 +106,7 @@
:event-styles="EVENT_STYLES"
:global-event-counts="combinedGlobalCounts"
:player-event-counts="combinedPlayerCounts"
:selected-player-uuid="selectedUuid"
:selected-player-uuid="selectedUuids.length ? selectedUuids[0] : ''"
:player-bar-gradient="playerBarGradient"
view-mode="ratio"
:loading="loading"
@@ -201,21 +201,18 @@ const allPlayersWithScores = ref<any[]>([]);
// Function to check if a score passes the current filters
function scorePassesFilters(score: any, roomId: string) {
// Room filter
if (eventFilters.roomFilter.value !== 'all' && roomId !== eventFilters.roomFilter.value) {
return false;
}
// Round filter
if (eventFilters.roundFilter.value !== 'all' && score.round !== eventFilters.roundFilter.value) {
return false;
}
// Game variant filter
if (eventFilters.gameFilter.value !== 'all' && score.variant !== eventFilters.gameFilter.value) {
return false;
}
// Room filter (array): empty means all
const rf = eventFilters.roomFilter.value;
if (rf.length && !rf.includes(String(roomId))) return false;
// Round filter (array)
const rds = eventFilters.roundFilter.value;
if (rds.length && !rds.includes(Number(score.round))) return false;
// Game variant filter (array)
const gfs = eventFilters.gameFilter.value;
if (gfs.length && !gfs.includes(String(score.variant))) return false;
return true;
}
@@ -274,16 +271,16 @@ function computeMetricsFromScores() {
};
}
// Function to compute metrics for a selected player
function computeSelectedPlayerMetrics(uuid: string) {
const playerData = allPlayersWithScores.value.find(p => p.uuid === uuid);
if (!playerData) {
// Function to compute metrics for selected players (multi-select)
function computeSelectedPlayersMetrics() {
const uuids = selectedUuids.value;
if (!uuids.length) {
selectedPlayerMetrics.value = {
players_seated: 1,
players_seated: 0,
score_p1: 0,
score_p2: 0,
players_with_shame: 0,
players_without_shame: 1
players_without_shame: 0
};
return;
}
@@ -292,34 +289,29 @@ function computeSelectedPlayerMetrics(uuid: string) {
let totalP2Scores = 0;
let p1Count = 0;
let p2Count = 0;
const hasShame = playerData.shameTokens && playerData.shameTokens > 0;
let playersWithShame = 0;
if (playerData.roomScoreHistory) {
playerData.roomScoreHistory.forEach((roomScore: any) => {
roomScore.scores.forEach((score: any) => {
// Apply filters to scores
if (!scorePassesFilters(score, roomScore.roomId)) {
return;
}
if (score.role === 'P1') {
totalP1Scores += score.score;
p1Count++;
} else if (score.role === 'P2') {
totalP2Scores += score.score;
p2Count++;
}
uuids.forEach(uuid => {
const playerData = allPlayersWithScores.value.find(p => p.uuid === uuid);
if (!playerData) return;
if (playerData.shameTokens && playerData.shameTokens > 0) playersWithShame++;
if (playerData.roomScoreHistory) {
playerData.roomScoreHistory.forEach((roomScore: any) => {
roomScore.scores.forEach((score: any) => {
if (!scorePassesFilters(score, roomScore.roomId)) return;
if (score.role === 'P1') { totalP1Scores += score.score; p1Count++; }
else if (score.role === 'P2') { totalP2Scores += score.score; p2Count++; }
});
});
});
}
}
});
selectedPlayerMetrics.value = {
players_seated: 1,
players_seated: uuids.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: hasShame ? 1 : 0,
players_without_shame: hasShame ? 0 : 1
players_with_shame: playersWithShame,
players_without_shame: uuids.length - playersWithShame
};
}
@@ -400,11 +392,11 @@ const totalPlayersCount = computed(() => {
// Active filters object
const activeFilters = computed(() => ({
dataSource: eventFilters.dataSource.value,
round: eventFilters.roundFilter.value.toString(),
game: eventFilters.gameFilter.value,
hasFilters: eventFilters.hasActiveFilters.value || !!selectedRoomId.value,
selectedPlayer: selectedUuid.value ? players.value.find(p => p.uuid === selectedUuid.value)?.name || 'Jugador' : undefined,
selectedRoom: selectedRoomId.value ? availableRooms.value.find(r => r.roomId === selectedRoomId.value)?.name || 'Sala' : undefined
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
}));
// Watch for changes in filters and data source
@@ -449,7 +441,7 @@ const containerWidth = ref(1200); // Default width, will be updated dynamically
const dynamicPageSize = computed(() => {
// Estimate space needed per chip and controls
const chipWidth = 140; // Average chip width including gap
const clearBtnWidth = selectedUuid.value ? 150 : 0; // "Quitar selección" button
const 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
@@ -463,7 +455,7 @@ const dynamicPageSize = computed(() => {
const roomDynamicPageSize = computed(() => {
// Similar calculation for rooms
const chipWidth = 160; // Room chips might be slightly wider
const clearBtnWidth = selectedRoomId.value ? 150 : 0;
const clearBtnWidth = selectedRoomIds.value.length ? 150 : 0;
const searchWidth = 240;
const paginationWidth = roomPageCount.value > 1 ? 100 : 0;
const margin = 40;
@@ -487,18 +479,17 @@ const roomsPage = computed(() => {
return roomsFiltered.value.slice(start, start + roomDynamicPageSize.value);
});
const selectedUuid = ref('');
const selectedRoomId = ref('');
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 player changes to update their metrics
watch(selectedUuid, (newUuid) => {
if (newUuid) {
computeSelectedPlayerMetrics(newUuid);
}
});
// Watch for selected players changes to update their metrics
watch(selectedUuids, () => {
computeSelectedPlayersMetrics();
updateSelectedPlayersCounts();
}, { deep: true });
function initials(name: string): string {
const n = (name || '').trim();
@@ -509,26 +500,29 @@ function initials(name: string): string {
}
function selectPlayer(uuid: string) {
if (selectedUuid.value === uuid) return;
selectedUuid.value = uuid;
loadPlayerHistory();
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 clearPlayer() {
selectedUuid.value = '';
function clearPlayers() {
selectedUuids.value = [];
updateSelectedPlayersCounts();
}
function selectRoom(roomId: string) {
if (selectedRoomId.value === roomId) return;
selectedRoomId.value = roomId;
eventFilters.roomFilter.value = roomId;
function toggleRoom(roomId: string) {
const idx = selectedRoomIds.value.indexOf(roomId);
if (idx >= 0) selectedRoomIds.value.splice(idx, 1);
else selectedRoomIds.value.push(roomId);
eventFilters.roomFilter.value = [...selectedRoomIds.value];
eventFilters.applyFilters(EVENTS);
}
function clearRoom() {
selectedRoomId.value = '';
eventFilters.roomFilter.value = 'all';
function clearRooms() {
selectedRoomIds.value = [];
eventFilters.roomFilter.value = [];
eventFilters.applyFilters(EVENTS);
}
@@ -555,11 +549,7 @@ watch(roomPageCount, (newCount) => {
});
// Dynamic per-player overlay bar gradient and label color
const playerBarGradient = computed(() => {
const p = players.value.find(x => x.uuid === selectedUuid.value);
const c = p?.color || '#667eea';
return `linear-gradient(90deg, ${c}, ${c})`;
});
const playerBarGradient = computed(() => '#8b5cf6');
@@ -720,10 +710,8 @@ function setupStreams() {
eventFilters.applyFilters(EVENTS);
}
// If a player is selected, update playerEventCounts live
if (selectedUuid.value) {
const counts = byUuid[selectedUuid.value];
if (counts) playerEventCounts.value = counts as any;
}
// Update selected players combined counts
updateSelectedPlayersCounts();
// Merge names into players list; preserve colors from previous streams
const existing = new Map<string, { name: string; color?: string }>();
players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color }));
@@ -782,13 +770,14 @@ async function refreshAll() {
}
}
async function loadPlayerHistory() {
function updateSelectedPlayersCounts() {
playerLoading.value = true;
const next: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
const counts = playersActionsByUuid.value[selectedUuid.value || ''];
if (counts) {
EVENTS.forEach(k => { next[k] = Number(counts[k] || 0); });
}
const byUuid = playersActionsByUuid.value;
selectedUuids.value.forEach(uuid => {
const counts = byUuid[uuid] || {};
EVENTS.forEach(k => { next[k] = (next[k] || 0) + Number(counts[k] || 0); });
});
playerEventCounts.value = next as any;
playerLoading.value = false;
}
@@ -835,14 +824,8 @@ function downloadJSON() {
game: eventFilters.gameFilter.value,
hasActiveFilters: eventFilters.hasActiveFilters.value,
filterSummary: eventFilters.filterSummary.value,
selectedPlayer: selectedUuid.value ? {
uuid: selectedUuid.value,
name: players.value.find(p => p.uuid === selectedUuid.value)?.name || 'Jugador'
} : null,
selectedRoom: selectedRoomId.value ? {
roomId: selectedRoomId.value,
name: availableRooms.value.find(r => r.roomId === selectedRoomId.value)?.name || 'Sala'
} : null
selectedPlayers: selectedUuids.value,
selectedRooms: selectedRoomIds.value
}
},
@@ -861,17 +844,17 @@ function downloadJSON() {
globalEventCounts: eventFilters.globalEventCounts.value,
// Selected player event counts (if applicable)
selectedPlayerEventCounts: selectedUuid.value ? playerEventCounts.value : null,
selectedPlayersEventCounts: selectedUuids.value.length ? playerEventCounts.value : null,
// Additional metrics being displayed
additionalMetrics: additionalMetrics.value,
// Selected player metrics (if applicable)
selectedPlayerMetrics: selectedUuid.value ? selectedPlayerMetrics.value : null,
selectedPlayersMetrics: selectedUuids.value.length ? selectedPlayerMetrics.value : null,
// Combined counts used in charts
combinedGlobalCounts: combinedGlobalCounts.value,
combinedPlayerCounts: selectedUuid.value ? combinedPlayerCounts.value : null,
combinedPlayerCounts: selectedUuids.value.length ? combinedPlayerCounts.value : null,
// Players data
players: players.value,

View File

@@ -2,8 +2,14 @@
<div class="offer-card" :class="{ disabled: isFinished }" :aria-disabled="isFinished ? 'true' : 'false'">
<div v-if="isFinished" class="banner finished">🏁 Juego finalizado</div>
<!-- Header (tap 5x to unlock Básico) -->
<div class="offer-header" :class="{ clickable: !advancedUnlocked }" @click="onHeaderClick" :title="unlockTitle">
Controles de oferta
</div>
<!-- Mode Toggle -->
<div class="mode-toggle" v-if="!isFinished">
<!-- Show toggles only after unlocking (5 taps) -->
<div class="mode-toggle" v-if="!isFinished && advancedUnlocked">
<button
class="mode-btn"
:class="{ active: !advancedMode }"
@@ -12,6 +18,7 @@
🎯 Básico
</button>
<button
v-if="advancedUnlocked"
class="mode-btn"
:class="{ active: advancedMode }"
@click="advancedMode = true"
@@ -20,8 +27,8 @@
</button>
</div>
<!-- Basic Mode -->
<div v-if="!advancedMode" class="basic-mode">
<!-- Basic Mode (always visible until advanced is unlocked and selected) -->
<div v-if="!advancedUnlocked || !advancedMode" class="basic-mode">
<div class="basic-offer">
<div class="offer-text">
Ofrecer <span class="token pill">
@@ -112,7 +119,25 @@ const offerPavo = ref(0);
const offerElote = ref(0);
const requestPavo = ref(0);
const requestElote = ref(0);
const advancedMode = ref(false); // Start in basic mode
const advancedMode = ref(false); // Start in basic mode; 'Avanzado' unlocks after 5 taps
// Hidden unlock: 5 rapid taps on header to reveal "Avanzado"
const advancedUnlocked = ref(false);
const clickCount = ref(0);
let clickResetTimer: any = null;
function onHeaderClick() {
if (advancedUnlocked.value) return;
if (clickResetTimer) { clearTimeout(clickResetTimer); clickResetTimer = null; }
clickCount.value += 1;
if (clickCount.value >= 5) {
advancedUnlocked.value = true;
} else {
clickResetTimer = setTimeout(() => { clickCount.value = 0; }, 1200);
}
}
const unlockTitle = computed(() => advancedUnlocked.value ? 'Modo Avanzado disponible' : `Clicks: ${clickCount.value}/5 para desbloquear Avanzado`);
const room = computed(() => colyseusService.gameRoom.value as any);
const isFinished = ref(false);
@@ -263,6 +288,9 @@ function noOffer() {
.offer-card.disabled { opacity: 0.6; filter: grayscale(0.15); pointer-events: none; }
.banner { margin-bottom:8px; padding:8px 10px; border-radius:10px; font-weight:700; font-size:13px; display:flex; align-items:center; gap:8px; }
.banner.finished { background:#f8fafc; border:1px solid #e5e9f0; color:#334155; }
.offer-header { font-weight: 800; font-size: 14px; color:#334155; margin: 4px 2px 8px; }
.offer-header.clickable { cursor: pointer; user-select: none; opacity: 0.85; }
.offer-header.clickable:hover { filter: brightness(0.95); }
.offer-grid { display:grid; grid-template-columns: 1fr; gap:12px; }
@media (min-width: 500px) { .offer-grid { grid-template-columns: 1fr 1fr; } }