diff --git a/backend/app/core/brackets.py b/backend/app/core/brackets.py
index 0b03b8c..bad0094 100644
--- a/backend/app/core/brackets.py
+++ b/backend/app/core/brackets.py
@@ -168,6 +168,8 @@ class BracketGenerator:
self.match_counter += 1
gf.next_loss = reset
gf.next_loss_slot = 0
+ gf.next_win = reset
+ gf.next_win_slot = 1
def _resolve_byes(self, num_players: int) -> None:
"""
diff --git a/backend/app/logic.py b/backend/app/logic.py
index c7aede2..b812f09 100644
--- a/backend/app/logic.py
+++ b/backend/app/logic.py
@@ -6,7 +6,7 @@ from uuid import uuid4
from sqlalchemy.orm import Session
from . import models
-from .constants import MatchStatus, TournamentTypes
+from .constants import BracketTypes, MatchStatus, TournamentTypes
from .core.brackets import BracketGenerator
@@ -181,6 +181,25 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
+ if (
+ match.bracket_type == BracketTypes.FINALS
+ and match.winner_next_match
+ and match.winner_next_match.bracket_type == BracketTypes.FINALS
+ ):
+ if winner_id == match.p1_team_id:
+ reset_match = match.winner_next_match
+ if reset_match.winner_team_id:
+ undo_advancement(db, reset_match)
+ reset_match.winner_team_id = None
+ reset_match.sets = []
+ reset_match.p1_team_id = None
+ reset_match.p2_team_id = None
+ reset_match.status = MatchStatus.SCHEDULED
+
+ db.add(reset_match)
+ db.commit()
+ return
+
def update_next_match(
next_match: models.Match, team_id: int, target_slot: int | None
):
@@ -214,9 +233,7 @@ def undo_advancement(db: Session, match: models.Match):
winner_id = match.winner_team_id
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
- def clear_from_next(
- next_match: models.Match, team_id: int, target_slot: int | None
- ):
+ def clear_from_next(next_match: models.Match, target_slot: int | None):
if not next_match or target_slot is None:
return
@@ -230,11 +247,10 @@ def undo_advancement(db: Session, match: models.Match):
elif target_slot == 1:
next_match.p2_team_id = None
- if next_match.status == MatchStatus.PENDING:
- next_match.status = MatchStatus.SCHEDULED
+ next_match.status = MatchStatus.SCHEDULED
db.add(next_match)
- clear_from_next(match.winner_next_match, winner_id, match.winner_next_match_slot)
+ clear_from_next(match.winner_next_match, match.winner_next_match_slot)
if match.loser_next_match and loser_id:
- clear_from_next(match.loser_next_match, loser_id, match.loser_next_match_slot)
+ clear_from_next(match.loser_next_match, match.loser_next_match_slot)
diff --git a/frontend/src/components/Bracket/BracketView.jsx b/frontend/src/components/Bracket/BracketView.jsx
index b0de91a..cdf9322 100644
--- a/frontend/src/components/Bracket/BracketView.jsx
+++ b/frontend/src/components/Bracket/BracketView.jsx
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react';
import MatchCard from "./MatchCard";
import Podium from './Podium';
-
export default function BracketView({ matches, onMatchClick }) {
const containerRef = useRef(null);
const [lines, setLines] = useState([]);
@@ -14,23 +13,58 @@ export default function BracketView({ matches, onMatchClick }) {
if (!containerRef.current) return;
const container = containerRef.current.getBoundingClientRect();
const newLines = [];
+
matches.forEach(m => {
- if (!m.winner_next_match_id) return;
- const sEl = document.getElementById(`match-${m.id}`);
- const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
- if (sEl && eEl) {
- const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
- const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
- const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
- const c1 = sx + (ex - sx) / 2;
- newLines.push();
+ if (m.winner_next_match_id) {
+ const sEl = document.getElementById(`match-${m.id}`);
+ const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
+ if (sEl && eEl) {
+ const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
+ const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
+ const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
+ const c1 = sx + (ex - sx) / 2;
+ newLines.push(
+
+ );
+ }
+ }
+
+ if (m.loser_next_match_id) {
+ const targetMatch = matches.find(x => x.id === m.loser_next_match_id);
+ if (targetMatch && targetMatch.bracket === 'Finals') {
+ const sEl = document.getElementById(`match-${m.id}`);
+ const eEl = document.getElementById(`match-${targetMatch.id}`);
+ if (sEl && eEl) {
+ const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
+ const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
+ const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
+ const c1 = sx + (ex - sx) / 2;
+ newLines.push(
+
+ );
+ }
+ }
}
});
setLines(newLines);
};
+
const t = setTimeout(draw, 100);
window.addEventListener('resize', draw);
- return () => { clearTimeout(t); window.removeEventListener('resize', draw); };
+
+ const handlePrint = () => { draw(); setTimeout(draw, 100); };
+ const mql = window.matchMedia('print');
+ mql.addEventListener('change', handlePrint);
+ window.addEventListener('beforeprint', handlePrint);
+ window.addEventListener('afterprint', draw);
+
+ return () => {
+ clearTimeout(t);
+ window.removeEventListener('resize', draw);
+ mql.removeEventListener('change', handlePrint);
+ window.removeEventListener('beforeprint', handlePrint);
+ window.removeEventListener('afterprint', draw);
+ };
}, [matches]);
const renderRound = (list) => {
@@ -66,43 +100,64 @@ export default function BracketView({ matches, onMatchClick }) {
});
};
+ const isDoubleElim = matches.some(m => m.bracket === 'Loser');
const wb = matches.filter(m => m.bracket === 'Winner');
const lb = matches.filter(m => m.bracket === 'Loser');
const finals = matches.filter(m => m.bracket === 'Finals');
+ let displayWb = [...wb];
+ let displayFinals = [...finals];
+
+ if (!isDoubleElim && displayWb.length > 0) {
+ const maxRound = Math.max(...displayWb.map(m => m.round));
+ const gfIndex = displayWb.findIndex(m => m.round === maxRound);
+
+ if (gfIndex !== -1) {
+ const gfMatch = displayWb.splice(gfIndex, 1)[0];
+ displayFinals.unshift(gfMatch);
+ }
+ }
+
return (
-
+
+
+
-
);
diff --git a/frontend/src/components/Bracket/MatchCard.jsx b/frontend/src/components/Bracket/MatchCard.jsx
index 48b965f..367c326 100644
--- a/frontend/src/components/Bracket/MatchCard.jsx
+++ b/frontend/src/components/Bracket/MatchCard.jsx
@@ -1,7 +1,7 @@
// frontend/src/components/Bracket/MatchCard.jsx
import { Check } from 'lucide-react';
-import { stringToColor } from '../../utils/helpers';
+import { printName, stringToColor } from '../../utils/helpers';
export default function MatchCard({ match, onClick }) {
const isFinished = match.status === "Finished";
@@ -11,7 +11,6 @@ export default function MatchCard({ match, onClick }) {
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
const canInteract = match.hasTeams;
-
const cursorClass = canInteract
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
: 'cursor-default opacity-100';
@@ -20,44 +19,44 @@ export default function MatchCard({ match, onClick }) {
canInteract && onClick(match)}
- className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`}
+ className={`w-64 bg-white dark:bg-zinc-900 print:!bg-white rounded-lg border ${borderClass} print:!border-zinc-400 print:!shadow-none shadow-sm transition-all duration-200 print:transition-none relative z-10 flex flex-col ${cursorClass}`}
>
-
+
- # {match.number}
+ # {match.number}
{match.time && (
-
+
{match.court}
)}
{isFinished ? (
-
+
) : (
-
{match.time || 'TBD'}
+
{match.time || 'TBD'}
)}
-
+
{[
- {
- n: match.p1,
- s: match.p1_sets,
- win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id,
- real: match.p1_team_id
- },
- {
- n: match.p2,
- s: match.p2_sets,
- win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id,
- real: match.p2_team_id
- }
+ { n: match.p1, s: match.p1_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, real: match.p1_is_real },
+ { n: match.p2, s: match.p2_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, real: match.p2_is_real }
].map((p, i) => (
-
-
{p.n}
-
+
+
+ {p.n}
+
+
+ {printName(p.n)}
+
+
+
{p.s}
+
+
+ {isFinished ? p.s : ''}
+
))}
diff --git a/frontend/src/components/Bracket/Podium.jsx b/frontend/src/components/Bracket/Podium.jsx
index 5042576..7236833 100644
--- a/frontend/src/components/Bracket/Podium.jsx
+++ b/frontend/src/components/Bracket/Podium.jsx
@@ -1,10 +1,10 @@
// frontend/src/components/Bracket/Podium.jsx
-import React from 'react';
import { Trophy } from 'lucide-react';
export default function Podium({ matches }) {
const isDoubleElim = matches.some(m => m.bracket === 'Loser');
+
const wb = matches.filter(m => m.bracket === 'Winner').sort((a, b) => a.round - b.round);
const lb = matches.filter(m => m.bracket === 'Loser').sort((a, b) => a.round - b.round);
const finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round);
@@ -28,21 +28,23 @@ export default function Podium({ matches }) {
{ rank: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false }
];
+ // --- CALCULATE 3RD PLACE ---
if (tpMatch) {
if (tpMatch.isFinished && tpMatch.winnerName) {
- if (isDoubleElim) {
- podium[2].team = tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1;
- } else {
- podium[2].team = tpMatch.winnerName;
- }
+ // If match is done, grab the actual team name
+ podium[2].team = isDoubleElim
+ ? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1) // DE: Loser of LB Final
+ : tpMatch.winnerName; // SE: Winner of 3rd Place Match
podium[2].isReal = true;
} else {
+ // Set the exact string expected by the print formatter
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
}
} else {
podium[2].hidden = true;
}
+ // --- CALCULATE 1ST & 2ND PLACE ---
if (resetMatch && resetMatch.isFinished && resetMatch.winnerName) {
podium[0].team = resetMatch.winnerName;
podium[0].isReal = true;
@@ -50,39 +52,56 @@ export default function Podium({ matches }) {
podium[1].isReal = true;
} else if (gfMatch) {
if (gfMatch.isFinished && gfMatch.winnerName) {
+ // Has the loser bracket champ won, forcing a reset?
const isResetForced = resetMatch && resetMatch.hasTeams;
if (!isResetForced) {
+ // GF Winner is 1st, GF Loser is 2nd
podium[0].team = gfMatch.winnerName;
podium[0].isReal = true;
podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1;
podium[1].isReal = true;
} else {
+ // Reset forced, wait for the final match
podium[0].team = `Winner of #${resetMatch.number}`;
podium[1].team = `Loser of #${resetMatch.number}`;
}
} else {
+ // GF not finished yet
podium[0].team = `Winner of #${gfMatch.number}`;
podium[1].team = `Loser of #${gfMatch.number}`;
}
}
+ // Helper for rendering print strings (Takes "Winner of #7" -> "W#7: _______")
+ const printName = (name) => {
+ if (!name) return '';
+ if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W#') + ': _______';
+ if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L#') + ': _______';
+ return name;
+ };
+
return (
-
-
-
- Final Standings
+
+
+
+ Final Standings
{podium.filter(p => !p.hidden).map(p => (
-
+
{p.rank}
-
+ {/* Web Label */}
+
{p.team}
+ {/* Print Label */}
+
+ {printName(p.team)}
+
))}
diff --git a/frontend/src/components/Dashboard/DashCard.jsx b/frontend/src/components/Dashboard/DashCard.jsx
new file mode 100644
index 0000000..e7d19d7
--- /dev/null
+++ b/frontend/src/components/Dashboard/DashCard.jsx
@@ -0,0 +1,48 @@
+// frontend/src/components/Dashboard/DashCard.jsx
+
+import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
+
+export default function DashCard({ t, isAdmin, onSelect, onEdit }) {
+ return (
+
onSelect(t.id)}
+ className="block bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border border-zinc-200 dark:border-zinc-800 relative group hover:shadow-md hover:scale-[1.02] transition-all duration-200 will-change-transform transform-gpu cursor-pointer"
+ >
+
+
+
+ {t.name}
+
+
+ {t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'}
+ {t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
+
+
+
+ {t.type}
+
+
+
+
+
+
+ {t.team_count} Teams
+
+
+
+ {t.court_count} Courts
+
+
+ {isAdmin && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Forms/TournamentForm.jsx b/frontend/src/components/Forms/TournamentForm.jsx
index 9872eb1..bba81fc 100644
--- a/frontend/src/components/Forms/TournamentForm.jsx
+++ b/frontend/src/components/Forms/TournamentForm.jsx
@@ -63,7 +63,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
try {
if (tournamentId) {
- // UPDATE: Dispatch the 3 separated PATCH endpoints concurrently
const basePayload = {
name,
code,
@@ -78,7 +77,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
api.patch(`/tournaments/${tournamentId}/courts`, courts)
]);
} else {
- // CREATE: Send the full monolithic payload to POST /tournaments
const fullPayload = {
name,
code,
@@ -121,7 +119,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
}
return (
- // Add a key mapped to the ID so React completely rebuilds the form when data loads