From 0f98bc2d55f0a3e9d0364ce42868f7e905262604 Mon Sep 17 00:00:00 2001 From: Maik Labod Date: Thu, 12 Jun 2025 14:36:23 +0200 Subject: [PATCH] Add boolean match played status; Differentiate between upcoming and past matches in dashboard and results page --- backend/bracket/models/db/match.py | 6 +- backend/bracket/routes/matches.py | 21 +- backend/bracket/schema.py | 1 + backend/bracket/sql/matches.py | 31 +-- frontend/public/locales/de/common.json | 4 +- frontend/public/locales/en/common.json | 4 +- frontend/src/components/dashboard/layout.tsx | 1 + .../src/components/modals/match_modal.tsx | 1 + frontend/src/interfaces/match.tsx | 1 + .../tournaments/[id]/dashboard/index.tsx | 15 +- .../tournaments/[id]/dashboard/results.tsx | 200 ++++++++++++++++++ .../src/pages/tournaments/[id]/results.tsx | 43 +++- 12 files changed, 268 insertions(+), 60 deletions(-) create mode 100644 frontend/src/pages/tournaments/[id]/dashboard/results.tsx diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py index 32d4f3224..898918419 100644 --- a/backend/bracket/models/db/match.py +++ b/backend/bracket/models/db/match.py @@ -24,6 +24,7 @@ class MatchBaseInsertable(BaseModelORM): court_id: CourtId | None = None stage_item_input1_conflict: bool stage_item_input2_conflict: bool + played: bool = False @property def end_time(self) -> datetime_utc: @@ -60,9 +61,7 @@ class MatchWithDetails(Match): court: Court | None = None -def get_match_hash( - stage_item_input1_id: StageItemInputId | None, stage_item_input2_id: StageItemInputId | None -) -> str: +def get_match_hash(stage_item_input1_id: StageItemInputId | None, stage_item_input2_id: StageItemInputId | None) -> str: return f"{stage_item_input1_id}-{stage_item_input2_id}" @@ -93,6 +92,7 @@ class MatchBody(BaseModelORM): court_id: CourtId | None = None custom_duration_minutes: int | None = None custom_margin_minutes: int | None = None + played: bool = False class MatchCreateBodyFrontend(BaseModelORM): diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 5076085e6..988008f5e 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from starlette import status +from bracket.logger import get_logger from bracket.logic.planning.conflicts import handle_conflicts from bracket.logic.planning.matches import ( get_scheduled_matches, @@ -8,14 +9,9 @@ reorder_matches_for_court, schedule_all_unscheduled_matches, ) -from bracket.logic.ranking.calculation import ( - recalculate_ranking_for_stage_item, -) +from bracket.logic.ranking.calculation import recalculate_ranking_for_stage_item from bracket.logic.ranking.elimination import update_inputs_in_subsequent_elimination_rounds -from bracket.logic.scheduling.upcoming_matches import ( - get_draft_round_in_stage_item, - get_upcoming_matches_for_swiss, -) +from bracket.logic.scheduling.upcoming_matches import get_draft_round_in_stage_item, get_upcoming_matches_for_swiss from bracket.models.db.match import ( Match, MatchBody, @@ -41,6 +37,7 @@ from bracket.utils.types import assert_some router = APIRouter() +logger = get_logger("bracket") @router.get( @@ -68,9 +65,7 @@ async def get_matches_to_schedule( if len(courts) <= len(draft_round.matches): return UpcomingMatchesResponse(data=[]) - return UpcomingMatchesResponse( - data=get_upcoming_matches_for_swiss(match_filter, stage_item, draft_round) - ) + return UpcomingMatchesResponse(data=get_upcoming_matches_for_swiss(match_filter, stage_item, draft_round)) @router.delete("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse) @@ -136,9 +131,7 @@ async def schedule_matches( return SuccessResponse() -@router.post( - "/tournaments/{tournament_id}/matches/{match_id}/reschedule", response_model=SuccessResponse -) +@router.post("/tournaments/{tournament_id}/matches/{match_id}/reschedule", response_model=SuccessResponse) async def reschedule_match( tournament_id: TournamentId, match_id: MatchId, @@ -164,6 +157,8 @@ async def update_match_by_id( await check_foreign_keys_belong_to_tournament(match_body, tournament_id) tournament = await sql_get_tournament(tournament_id) + logger.info(f"Updating match {match_id} with body: {match_body.model_dump()}") + await sql_update_match(match_id, match_body, tournament) round_ = await get_round_by_id(tournament_id, match.round_id) diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 926357de3..784b37ae8 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -139,6 +139,7 @@ Column("stage_item_input1_score", Integer, nullable=False), Column("stage_item_input2_score", Integer, nullable=False), Column("position_in_schedule", Integer, nullable=True), + Column("played", Boolean, nullable=False), ) teams = Table( diff --git a/backend/bracket/sql/matches.py b/backend/bracket/sql/matches.py index 39adecc59..c82a36545 100644 --- a/backend/bracket/sql/matches.py +++ b/backend/bracket/sql/matches.py @@ -53,7 +53,8 @@ async def sql_create_match(match: MatchCreateBody) -> Match: stage_item_input2_score, stage_item_input1_conflict, stage_item_input2_conflict, - created + created, + played ) VALUES ( :round_id, @@ -70,7 +71,8 @@ async def sql_create_match(match: MatchCreateBody) -> Match: 0, false, false, - NOW() + NOW(), + false ) RETURNING * """ @@ -92,20 +94,17 @@ async def sql_update_match(match_id: MatchId, match: MatchBody, tournament: Tour custom_duration_minutes = :custom_duration_minutes, custom_margin_minutes = :custom_margin_minutes, duration_minutes = :duration_minutes, - margin_minutes = :margin_minutes + margin_minutes = :margin_minutes, + played = :played WHERE matches.id = :match_id RETURNING * """ duration_minutes = ( - match.custom_duration_minutes - if match.custom_duration_minutes is not None - else tournament.duration_minutes + match.custom_duration_minutes if match.custom_duration_minutes is not None else tournament.duration_minutes ) margin_minutes = ( - match.custom_margin_minutes - if match.custom_margin_minutes is not None - else tournament.margin_minutes + match.custom_margin_minutes if match.custom_margin_minutes is not None else tournament.margin_minutes ) await database.execute( query=query, @@ -189,15 +188,9 @@ async def sql_reschedule_match_and_determine_duration_and_margin( tournament: Tournament, ) -> None: duration_minutes = ( - tournament.duration_minutes - if match.custom_duration_minutes is None - else match.custom_duration_minutes - ) - margin_minutes = ( - tournament.margin_minutes - if match.custom_margin_minutes is None - else match.custom_margin_minutes + tournament.duration_minutes if match.custom_duration_minutes is None else match.custom_duration_minutes ) + margin_minutes = tournament.margin_minutes if match.custom_margin_minutes is None else match.custom_margin_minutes await sql_reschedule_match( match.id, court_id, @@ -226,9 +219,7 @@ async def sql_get_match(match_id: MatchId) -> Match: return Match.model_validate(dict(result._mapping)) -async def clear_scores_for_matches_in_stage_item( - tournament_id: TournamentId, stage_item_id: StageItemId -) -> None: +async def clear_scores_for_matches_in_stage_item(tournament_id: TournamentId, stage_item_id: StageItemId) -> None: query = """ UPDATE matches SET stage_item_input1_score = 0, diff --git a/frontend/public/locales/de/common.json b/frontend/public/locales/de/common.json index a3da641bc..1c52e55e0 100644 --- a/frontend/public/locales/de/common.json +++ b/frontend/public/locales/de/common.json @@ -272,5 +272,7 @@ "win_distribution_text_draws": "zeichnet", "win_distribution_text_losses": "Niederlagen", "win_distribution_text_win": "Siege", - "win_points_input_label": "Punkte für einen Sieg" + "win_points_input_label": "Punkte für einen Sieg", + "results_tab_upcoming": "Bevorstehende Spiele", + "results_tab_past": "Vergangene Spiele" } \ No newline at end of file diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 620157a64..0f1186efe 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -272,5 +272,7 @@ "win_distribution_text_draws": "draws", "win_distribution_text_losses": "losses", "win_distribution_text_win": "wins", - "win_points_input_label": "Points for a win" + "win_points_input_label": "Points for a win", + "results_tab_upcoming": "Upcoming Matches", + "results_tab_past": "Past Matches" } \ No newline at end of file diff --git a/frontend/src/components/dashboard/layout.tsx b/frontend/src/components/dashboard/layout.tsx index 1ca39abf3..a993dcfee 100644 --- a/frontend/src/components/dashboard/layout.tsx +++ b/frontend/src/components/dashboard/layout.tsx @@ -86,6 +86,7 @@ export function DoubleHeader({ tournamentData }: { tournamentData: Tournament }) const mainLinks = [ { link: `/tournaments/${endpoint}/dashboard`, label: 'Matches' }, + { link: `/tournaments/${endpoint}/dashboard/results`, label: 'Results' }, { link: `/tournaments/${endpoint}/dashboard/standings`, label: 'Standings' }, ]; diff --git a/frontend/src/components/modals/match_modal.tsx b/frontend/src/components/modals/match_modal.tsx index 75fb2d733..976b6a5bb 100644 --- a/frontend/src/components/modals/match_modal.tsx +++ b/frontend/src/components/modals/match_modal.tsx @@ -106,6 +106,7 @@ function MatchModalForm({ court_id: match.court_id, custom_duration_minutes: customDurationEnabled ? values.custom_duration_minutes : null, custom_margin_minutes: customMarginEnabled ? values.custom_margin_minutes : null, + played: true, }; await updateMatch(tournamentData.id, match.id, updatedMatch); await swrStagesResponse.mutate(); diff --git a/frontend/src/interfaces/match.tsx b/frontend/src/interfaces/match.tsx index a0bcc343f..df0ae06f1 100644 --- a/frontend/src/interfaces/match.tsx +++ b/frontend/src/interfaces/match.tsx @@ -32,6 +32,7 @@ export interface MatchBodyInterface { court_id: number | null; custom_duration_minutes: number | null; custom_margin_minutes: number | null; + played: boolean; } export interface MatchRescheduleInterface { diff --git a/frontend/src/pages/tournaments/[id]/dashboard/index.tsx b/frontend/src/pages/tournaments/[id]/dashboard/index.tsx index 4b78d7ac4..cdbda1205 100644 --- a/frontend/src/pages/tournaments/[id]/dashboard/index.tsx +++ b/frontend/src/pages/tournaments/[id]/dashboard/index.tsx @@ -132,6 +132,7 @@ export function Schedule({ const matches: any[] = Object.values(matchesLookup); const sortedMatches = matches .filter((m1: any) => m1.match.start_time != null) + .filter((m1: any) => m1.match.played === false) .sort( (m1: any, m2: any) => compareDateTime(m1.match.start_time, m2.match.start_time) || @@ -143,20 +144,6 @@ export function Schedule({ for (let c = 0; c < sortedMatches.length; c += 1) { const data = sortedMatches[c]; - if (c < 1 || sortedMatches[c - 1].match.start_time) { - const startTime = formatTime(data.match.start_time); - - if (c < 1 || startTime !== formatTime(sortedMatches[c - 1].match.start_time)) { - rows.push( -
- - {startTime} - -
- ); - } - } - rows.push( data.match.stage_item_input2_score + ? winColor + : data.match.stage_item_input1_score === data.match.stage_item_input2_score + ? drawColor + : loseColor; + const team2_color = + data.match.stage_item_input2_score > data.match.stage_item_input1_score + ? winColor + : data.match.stage_item_input1_score === data.match.stage_item_input2_score + ? drawColor + : loseColor; + + return ( + + + + + + + {formatMatchInput1(t, stageItemsLookup, matchesLookup, data.match)} + + + +
+
{data.match.stage_item_input1_score}
+ +
+
+
+
+ + + +
+
{data.match.stage_item_input2_score}
+
+
+ + + {formatMatchInput2(t, stageItemsLookup, matchesLookup, data.match)} + + + +
+
+
+
+ ); +} + +export function Schedule({ + t, + stageItemsLookup, + matchesLookup, +}: { + t: Translator; + stageItemsLookup: any; + matchesLookup: any; +}) { + const matches: any[] = Object.values(matchesLookup); + const sortedMatches = matches + .filter((m1: any) => m1.match.start_time != null) + .filter((m1: any) => m1.match.played) + .sort( + (m1: any, m2: any) => + compareDateTime(m1.match.start_time, m2.match.start_time) || + m1.match.court?.name.localeCompare(m2.match.court?.name) + ); + + const rows: React.JSX.Element[] = []; + + for (let c = 0; c < sortedMatches.length; c += 1) { + const data = sortedMatches[c]; + + rows.push( + + ); + } + + if (rows.length < 1) { + return } />; + } + + const noItemsAlert = + matchesLookup.length < 1 ? ( + } + title={t('no_matches_title')} + color="gray" + radius="md" + > + {t('drop_match_alert_title')} + + ) : null; + + return ( + +
+ {rows} + {noItemsAlert} +
+
+ ); +} + +export default function SchedulePage() { + const { t } = useTranslation(); + const tournamentResponse = getTournamentResponseByEndpointName(); + + const notFound = tournamentResponse == null || tournamentResponse[0] == null; + const tournamentId = !notFound ? tournamentResponse[0].id : null; + const tournamentDataFull = tournamentResponse != null ? tournamentResponse[0] : null; + + const swrStagesResponse = getStagesLive(tournamentId); + const swrCourtsResponse = getCourtsLive(tournamentId); + + const stageItemsLookup = responseIsValid(swrStagesResponse) + ? getStageItemLookup(swrStagesResponse) + : []; + const matchesLookup = responseIsValid(swrStagesResponse) ? getMatchLookup(swrStagesResponse) : []; + + if (!responseIsValid(swrStagesResponse)) return null; + if (!responseIsValid(swrCourtsResponse)) return null; + + return ( + <> + + + + +
+ + + +
+ + + ); +} + +export const getServerSideProps = async ({ locale }: { locale: string }) => ({ + props: { + ...(await serverSideTranslations(locale, ['common'])), + }, +}); diff --git a/frontend/src/pages/tournaments/[id]/results.tsx b/frontend/src/pages/tournaments/[id]/results.tsx index b36be7071..7c7a09cd1 100644 --- a/frontend/src/pages/tournaments/[id]/results.tsx +++ b/frontend/src/pages/tournaments/[id]/results.tsx @@ -26,6 +26,7 @@ import { MatchInterface, formatMatchInput1, formatMatchInput2 } from '../../../i import { getCourts, getStages } from '../../../services/adapter'; import { getMatchLookup, getStageItemLookup, stringToColour } from '../../../services/lookups'; import TournamentLayout from '../_tournament_layout'; +import { Tabs } from '@mantine/core'; function ScheduleRow({ data, @@ -150,15 +151,18 @@ function Schedule({ stageItemsLookup, openMatchModal, matchesLookup, + matchPlayedFilter, }: { t: Translator; stageItemsLookup: any; openMatchModal: CallableFunction; matchesLookup: any; + matchPlayedFilter: boolean; }) { const matches: any[] = Object.values(matchesLookup); const sortedMatches = matches .filter((m1: any) => m1.match.start_time != null) + .filter((m1: any) => m1.match.played === matchPlayedFilter) .sort((m1: any, m2: any) => (m1.match.court?.name > m2.match.court?.name ? 1 : -1)) .sort((m1: any, m2: any) => (m1.match.start_time > m2.match.start_time ? 1 : -1)); @@ -225,6 +229,7 @@ function Schedule({ } export default function SchedulePage() { + const [activeTab, setActiveTab] = useState('upcoming'); const [modalOpened, modalSetOpened] = useState(false); const [match, setMatch] = useState(null); @@ -265,14 +270,36 @@ export default function SchedulePage() { round={null} /> {t('results_title')} -
- -
+ + + + {t('results_tab_upcoming')} + {t('results_tab_past')} + + +
+ +
+
+ +
+ +
+
+
+ ); }