Label fixes, Websocket reconnects and more detailed court view cards.

This commit is contained in:
2026-06-14 20:57:19 +02:00 Verified
parent b904879326
commit 55a13934ef
8 changed files with 222 additions and 76 deletions
+16 -3
View File
@@ -4,7 +4,6 @@ import React, { useEffect, useState, useRef } from 'react';
import { Loader2, Trash, Plus } from 'lucide-react';
import { useOutletContext, Link } from 'react-router-dom';
import api from '../services/api';
import { printName } from '../utils/helpers';
import WhistleIcon from '../assets/whistle.svg?react';
import CourtIcon from '../assets/court.svg?react';
@@ -19,6 +18,8 @@ interface CourtMatch {
match_number: number;
p1: string;
p2: string;
p1_is_real: boolean;
p2_is_real: boolean;
p1_sets: number;
p2_sets: number;
ref_name?: string;
@@ -203,11 +204,23 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
<div className="space-y-0.5 mb-1.5">
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
<span className={`truncate pr-2
${isFinished && m.p1_sets > m.p2_sets ? 'font-bold text-orange-500' :
m.p1_is_real ? `font-bold ${pOpacity}` :
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
>
{m.p1}
</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p1_sets}</span>}
</div>
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p2_sets > m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)}</span>
<span className={`truncate pr-2
${isFinished && m.p2_sets > m.p1_sets ? 'font-bold text-orange-500' :
m.p2_is_real ? `font-bold ${pOpacity}` :
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
>
{m.p2}
</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p2_sets}</span>}
</div>
</div>
+38 -2
View File
@@ -1,6 +1,6 @@
// frontend/src/pages/Dashboard.tsx
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal } from 'lucide-react';
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal, Loader2 } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import DashCard from '../components/Dashboard/DashCard';
@@ -32,6 +32,7 @@ export default function Dashboard() {
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
const [showPast, setShowPast] = useState<boolean>(false);
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const loadDashboard = useCallback(async () => {
@@ -41,6 +42,8 @@ export default function Dashboard() {
setTournaments(list as TournamentType[]);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
@@ -54,6 +57,8 @@ export default function Dashboard() {
})();
let ws: WebSocket;
let reconnectTimeout: ReturnType<typeof setTimeout>;
const connect = () => {
try {
ws = new WebSocket(WS_URL);
@@ -61,13 +66,36 @@ export default function Dashboard() {
const msg = JSON.parse(e.data);
if (msg.type === 'dashboard_update') loadDashboard();
};
ws.onclose = () => {
reconnectTimeout = setTimeout(connect, 3000);
};
} catch (err) {
console.error("WebSocket connection failed", err);
}
};
connect();
return () => { if (ws) ws.close(); };
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
(async () => {
await loadDashboard();
})();
if (!ws || ws.readyState === WebSocket.CLOSED) {
clearTimeout(reconnectTimeout);
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearTimeout(reconnectTimeout);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (ws) {
ws.onclose = null;
ws.close();
}
};
}, [setNavTitle, setNavSubtitle, loadDashboard]);
const handleEdit = (t: TournamentType) => {
@@ -88,6 +116,14 @@ export default function Dashboard() {
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="animate-spin text-orange-600" size={48} />
</div>
);
}
const now = new Date();
const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
live: [],
+64 -23
View File
@@ -21,17 +21,22 @@ interface RawMatch {
round_number: number;
match_number: number;
winner_next_match_id?: string | number | null;
winner_next_match_slot?: number | null;
loser_next_match_id?: string | number | null;
loser_next_match_slot?: number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_team_id?: string | number | null;
ref_team_id?: string | number | null;
ref_label?: string | null;
ref_team?: { id: string | number; name: string } | null;
status: string;
court_id: string | number;
start_time?: string;
sets?: { p1: number; p2: number }[];
}
export interface ProcessedMatch extends RawMatch {
export interface ProcessedMatch extends Omit<RawMatch, 'ref_label' | 'ref_team'> {
bracket: string;
round: number;
number: number;
@@ -47,6 +52,8 @@ export interface ProcessedMatch extends RawMatch {
p2_sets: number;
hasTeams: boolean;
isFinished: boolean;
ref_label?: string;
ref_team?: { id: string | number; name: string };
}
interface OutletContextType {
@@ -88,26 +95,34 @@ export default function Tournament() {
const courtMap: Record<string | number, string> = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap: Record<string | number, string> = Object.fromEntries(teams.map(t => [t.id, t.name]));
const incoming: Record<string | number, { label: string; id: string | number }[]> = {};
rawMatches.forEach(m => {
const num = m.match_number;
if (m.winner_next_match_id) {
(incoming[m.winner_next_match_id] = incoming[m.winner_next_match_id] || []).push({ label: `Winner of #${num}`, id: m.id });
}
if (m.loser_next_match_id) {
(incoming[m.loser_next_match_id] = incoming[m.loser_next_match_id] || []).push({ label: `Loser of #${num}`, id: m.id });
}
});
return rawMatches
.filter(m => m.id !== skippedResetMatchId)
.map(m => {
const sources = incoming[m.id] || [];
let sourceIndex = 0;
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
// BUG 1 FIX: Explicitly check the SLOT (0 for top, 1 for bottom)
// to prevent duplicated labels.
const getPlaceholder = (slot: number) => {
const winParent = rawMatches.find(rm => rm.winner_next_match_id === m.id && rm.winner_next_match_slot === slot);
if (winParent) return `Winner of #${winParent.match_number}`;
const loseParent = rawMatches.find(rm => rm.loser_next_match_id === m.id && rm.loser_next_match_slot === slot);
if (loseParent) return `Loser of #${loseParent.match_number}`;
return 'TBD';
};
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : getPlaceholder(0);
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : getPlaceholder(1);
// BUG 2 FIX: Resolve Backend Graph Links ("W:uuid" / "L:uuid") for Referees
let resolvedRefLabel = m.ref_label;
if (resolvedRefLabel && resolvedRefLabel.includes(':')) {
const [outcome, refId] = resolvedRefLabel.split(':');
const refMatch = rawMatches.find(rm => String(rm.id) === String(refId));
if (refMatch) {
resolvedRefLabel = `${outcome === 'W' ? 'Winner' : 'Loser'} of #${refMatch.match_number}`;
}
}
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
const isFinished = m.status === "Finished";
@@ -116,6 +131,8 @@ export default function Tournament() {
return {
...m,
ref_label: resolvedRefLabel || undefined,
ref_team: m.ref_team || undefined,
bracket: m.bracket_type,
round: m.round_number,
number: m.match_number,
@@ -153,23 +170,47 @@ export default function Tournament() {
await fetchData();
})();
if (wsRef.current) return;
let reconnectTimeout: ReturnType<typeof setTimeout>;
const connect = () => {
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) void fetchData();
if (msg.type === 'tournament_update' && msg.id === id) {
(async () => {
await fetchData();
})();
}
};
ws.onclose = () => {
wsRef.current = null;
reconnectTimeout = setTimeout(connect, 3000);
};
ws.onclose = () => { wsRef.current = null; };
};
connect();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
(async () => {
await fetchData();
})();
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
clearTimeout(reconnectTimeout);
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
if (wsRef.current?.readyState === 1) wsRef.current.close();
wsRef.current = null;
clearTimeout(reconnectTimeout);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
};
}, [id, fetchData]);