From 932e5a245d8c380d920c4a28aa177b081067279f Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Wed, 6 Nov 2024 14:52:42 +0100 Subject: [PATCH] Show updates to stage item inputs when going to next stage (#966) --- .github/workflows/docker.yml | 2 +- .../e6e2718365dc_drop_rounds_is_active.py | 34 +++++++ backend/bracket/logic/planning/rounds.py | 15 +-- backend/bracket/logic/ranking/elo.py | 26 +++-- .../scheduling/handle_stage_activation.py | 59 +++++++---- .../bracket/logic/scheduling/ladder_teams.py | 9 +- .../logic/scheduling/upcoming_matches.py | 11 ++- backend/bracket/models/db/player.py | 2 +- backend/bracket/models/db/round.py | 2 - backend/bracket/routes/matches.py | 82 +++++----------- backend/bracket/routes/models.py | 8 +- backend/bracket/routes/rounds.py | 14 +-- backend/bracket/routes/stage_items.py | 97 ++++++++++++++++--- backend/bracket/routes/stages.py | 19 ++-- backend/bracket/routes/util.py | 9 +- backend/bracket/schema.py | 1 - backend/bracket/sql/rounds.py | 18 +--- .../api/auto_scheduling_matches_test.py | 88 +---------------- .../integration_tests/api/matches_test.py | 13 ++- .../integration_tests/api/rounds_test.py | 3 +- .../integration_tests/api/stages_test.py | 12 +-- backend/tests/unit_tests/swiss_test.py | 8 -- frontend/public/locales/en/common.json | 4 +- frontend/src/components/brackets/brackets.tsx | 56 +++-------- frontend/src/components/brackets/courts.tsx | 2 +- frontend/src/components/brackets/match.tsx | 13 +-- frontend/src/components/brackets/round.tsx | 26 ++--- frontend/src/components/builder/builder.tsx | 55 +++++++---- .../buttons/create_matches_auto.tsx | 49 ---------- .../src/components/buttons/create_stage.tsx | 3 + .../modals/activate_next_round_modal.tsx | 11 ++- .../modals/activate_next_stage_modal.tsx | 75 +++++++++++++- .../modals/activate_previous_stage_modal.tsx | 3 + .../src/components/modals/match_modal.tsx | 29 +++--- .../src/components/modals/round_modal.tsx | 63 ++++++------ .../src/components/scheduling/scheduling.tsx | 36 ++----- .../scheduling/settings/ladder_fixed.tsx | 34 ++++++- .../components/tables/upcoming_matches.tsx | 47 ++++++--- frontend/src/interfaces/match.tsx | 28 ++---- frontend/src/interfaces/round.tsx | 1 - frontend/src/interfaces/stage_item.tsx | 5 +- frontend/src/interfaces/stage_item_input.tsx | 26 ++++- .../src/pages/tournaments/[id]/results.tsx | 2 +- .../src/pages/tournaments/[id]/schedule.tsx | 2 +- .../pages/tournaments/[id]/stages/index.tsx | 5 + .../[id]/stages/swiss/[stage_item_id].tsx | 39 ++++---- frontend/src/services/adapter.tsx | 8 +- frontend/src/services/round.tsx | 26 ++--- 48 files changed, 601 insertions(+), 579 deletions(-) create mode 100644 backend/alembic/versions/e6e2718365dc_drop_rounds_is_active.py delete mode 100644 frontend/src/components/buttons/create_matches_auto.tsx diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a456dfb54..75e9c8a07 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,7 +12,7 @@ on: - 'master' jobs: - build: + build_docker: runs-on: ubuntu-22.04 steps: diff --git a/backend/alembic/versions/e6e2718365dc_drop_rounds_is_active.py b/backend/alembic/versions/e6e2718365dc_drop_rounds_is_active.py new file mode 100644 index 000000000..028bce947 --- /dev/null +++ b/backend/alembic/versions/e6e2718365dc_drop_rounds_is_active.py @@ -0,0 +1,34 @@ +"""drop rounds.is_active + +Revision ID: e6e2718365dc +Revises: 55b14b08be04 +Create Date: 2024-11-06 12:02:45.234018 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = "e6e2718365dc" +down_revision: str | None = "55b14b08be04" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.drop_column("rounds", "is_active") + + +def downgrade() -> None: + op.add_column( + "rounds", + sa.Column( + "is_active", + sa.BOOLEAN(), + server_default=sa.text("false"), + autoincrement=False, + nullable=False, + ), + ) diff --git a/backend/bracket/logic/planning/rounds.py b/backend/bracket/logic/planning/rounds.py index 9a99abdda..0da50bf71 100644 --- a/backend/bracket/logic/planning/rounds.py +++ b/backend/bracket/logic/planning/rounds.py @@ -16,20 +16,13 @@ class MatchTimingAdjustmentInfeasible(Exception): pass -def get_active_and_next_rounds( +def get_draft_round( stage_item: StageItemWithRounds, -) -> tuple[RoundWithMatches | None, RoundWithMatches | None]: - active_round = next((round_ for round_ in stage_item.rounds if round_.is_active), None) - - def is_round_in_future(round_: RoundWithMatches) -> bool: - return (round_.id > active_round.id) if active_round is not None else True - - rounds_chronologically_sorted = sorted(stage_item.rounds, key=lambda r: r.id) - next_round = next( - (round_ for round_ in rounds_chronologically_sorted if is_round_in_future(round_)), +) -> RoundWithMatches | None: + return next( + (round_ for round_ in sorted(stage_item.rounds, key=lambda r: r.id) if round_.is_draft), None, ) - return active_round, next_round async def schedule_all_matches_for_swiss_round( diff --git a/backend/bracket/logic/ranking/elo.py b/backend/bracket/logic/ranking/elo.py index c3c20a214..6acad3849 100644 --- a/backend/bracket/logic/ranking/elo.py +++ b/backend/bracket/logic/ranking/elo.py @@ -25,8 +25,6 @@ def set_statistics_for_stage_item_input( stats: defaultdict[StageItemInputId, TeamStatistics], match: MatchWithDetailsDefinitive, stage_item_input_id: StageItemInputId, - rating_team1_before: Decimal, - rating_team2_before: Decimal, ranking: Ranking, stage_item: StageItemWithRounds, ) -> None: @@ -61,7 +59,9 @@ def set_statistics_for_stage_item_input( stats[stage_item_input_id].points += swiss_score_diff case StageType.SWISS: - rating_diff = (rating_team2_before - rating_team1_before) * (1 if is_team1 else -1) + rating_diff = (match.stage_item_input2.elo - match.stage_item_input1.elo) * ( + 1 if is_team1 else -1 + ) expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D))) stats[stage_item_input_id].points += int(K * (swiss_score_diff - expected_score)) @@ -80,21 +80,17 @@ def determine_ranking_for_stage_item( if not round_.is_draft for match in round_.matches if isinstance(match, MatchWithDetailsDefinitive) - if match.stage_item_input1_score != 0 or match.stage_item_input2_score != 0 ] for match in matches: for team_index, stage_item_input in enumerate(match.stage_item_inputs): - if stage_item_input.id is not None: - set_statistics_for_stage_item_input( - team_index, - team_x_stats, - match, - stage_item_input.id, - match.stage_item_input1.elo, - match.stage_item_input2.elo, - ranking, - stage_item, - ) + set_statistics_for_stage_item_input( + team_index, + team_x_stats, + match, + stage_item_input.id, + ranking, + stage_item, + ) return team_x_stats diff --git a/backend/bracket/logic/scheduling/handle_stage_activation.py b/backend/bracket/logic/scheduling/handle_stage_activation.py index 263d90df6..476c1f3aa 100644 --- a/backend/bracket/logic/scheduling/handle_stage_activation.py +++ b/backend/bracket/logic/scheduling/handle_stage_activation.py @@ -1,8 +1,13 @@ +from collections import defaultdict + +from pydantic import BaseModel + from bracket.logic.ranking.elo import ( determine_team_ranking_for_stage_item, ) from bracket.logic.ranking.statistics import TeamStatistics from bracket.models.db.stage_item_inputs import StageItemInputFinal, StageItemInputTentative +from bracket.models.db.team import Team from bracket.models.db.util import StageWithStageItems from bracket.sql.rankings import get_ranking_for_stage_item from bracket.sql.stage_item_inputs import ( @@ -21,6 +26,11 @@ StageItemXTeamRanking = dict[StageItemId, list[tuple[StageItemInputId, TeamStatistics]]] +class StageItemInputUpdate(BaseModel): + stage_item_input: StageItemInputTentative + team: Team + + def determine_team_id( winner_from_stage_item_id: StageItemId, winner_position: int, @@ -41,11 +51,11 @@ def determine_team_id( return team_ranking[winner_position - 1][0] -async def set_team_ids_for_input( +async def get_team_update_for_input( tournament_id: TournamentId, stage_item_input: StageItemInputTentative, stage_item_x_team_rankings: StageItemXTeamRanking, -) -> None: +) -> StageItemInputUpdate: target_stage_item_input_id = determine_team_id( stage_item_input.winner_from_stage_item_id, stage_item_input.winner_position, @@ -55,24 +65,11 @@ async def set_team_ids_for_input( tournament_id, target_stage_item_input_id ) assert isinstance(target_stage_item_input, StageItemInputFinal) - await sql_set_team_id_for_stage_item_input( - tournament_id, stage_item_input.id, target_stage_item_input.team_id + return StageItemInputUpdate( + stage_item_input=stage_item_input, team=target_stage_item_input.team ) -async def get_team_rankings_lookup_for_stage( - tournament_id: TournamentId, stage: StageWithStageItems -) -> StageItemXTeamRanking: - stage_items = {stage_item.id: stage_item for stage_item in stage.stage_items} - return { - stage_item_id: determine_team_ranking_for_stage_item( - stage_item, - assert_some(await get_ranking_for_stage_item(tournament_id, stage_item.id)), - ) - for stage_item_id, stage_item in stage_items.items() - } - - async def get_team_rankings_lookup_for_tournament( tournament_id: TournamentId, stages: list[StageWithStageItems] ) -> StageItemXTeamRanking: @@ -88,9 +85,11 @@ async def get_team_rankings_lookup_for_tournament( } -async def update_matches_in_activated_stage(tournament_id: TournamentId, stage_id: StageId) -> None: +async def get_updates_to_inputs_in_activated_stage( + tournament_id: TournamentId, stage_id: StageId +) -> dict[StageItemId, list[StageItemInputUpdate]]: """ - Sets the team_id for stage item inputs of the newly activated stage. + Gets the team_id updates for stage item inputs of the newly activated stage. """ stages = await get_full_tournament_details(tournament_id) team_rankings_per_stage_item = await get_team_rankings_lookup_for_tournament( @@ -99,13 +98,31 @@ async def update_matches_in_activated_stage(tournament_id: TournamentId, stage_i activated_stage = next((stage for stage in stages if stage.id == stage_id), None) assert activated_stage + result = defaultdict(list) + for stage_item in activated_stage.stage_items: for stage_item_input in stage_item.inputs: if isinstance(stage_item_input, StageItemInputTentative): - await set_team_ids_for_input( - tournament_id, stage_item_input, team_rankings_per_stage_item + result[stage_item.id].append( + await get_team_update_for_input( + tournament_id, stage_item_input, team_rankings_per_stage_item + ) ) + return dict(result) + + +async def update_matches_in_activated_stage(tournament_id: TournamentId, stage_id: StageId) -> None: + """ + Sets the team_id for stage item inputs of the newly activated stage. + """ + updates_per_stage_item = await get_updates_to_inputs_in_activated_stage(tournament_id, stage_id) + for stage_item_updates in updates_per_stage_item.values(): + for update in stage_item_updates: + await sql_set_team_id_for_stage_item_input( + tournament_id, update.stage_item_input.id, update.team.id + ) + async def update_matches_in_deactivated_stage( tournament_id: TournamentId, deactivated_stage: StageWithStageItems diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py index 2b29ea8a6..6b80178fa 100644 --- a/backend/bracket/logic/scheduling/ladder_teams.py +++ b/backend/bracket/logic/scheduling/ladder_teams.py @@ -1,8 +1,6 @@ import random from collections import defaultdict -from fastapi import HTTPException - from bracket.logic.scheduling.shared import check_input_combination_adheres_to_filter from bracket.models.db.match import ( MatchFilter, @@ -58,15 +56,12 @@ def get_possible_upcoming_matches_for_swiss( filter_: MatchFilter, rounds: list[RoundWithMatches], stage_item_inputs: list[StageItemInput], + draft_round: RoundWithMatches | None = None, ) -> list[SuggestedMatch]: suggestions: list[SuggestedMatch] = [] scheduled_hashes: list[str] = [] - draft_round = next((round_ for round_ in rounds if round_.is_draft), None) - - if draft_round is None: - raise HTTPException(400, "There is no draft round, so no matches can be scheduled.") + draft_round_input_ids = get_draft_round_input_ids(draft_round) if draft_round else frozenset() - draft_round_input_ids = get_draft_round_input_ids(draft_round) inputs_to_schedule = [ input_ for input_ in stage_item_inputs if input_.id not in draft_round_input_ids ] diff --git a/backend/bracket/logic/scheduling/upcoming_matches.py b/backend/bracket/logic/scheduling/upcoming_matches.py index 2f36456de..9388610b0 100644 --- a/backend/bracket/logic/scheduling/upcoming_matches.py +++ b/backend/bracket/logic/scheduling/upcoming_matches.py @@ -2,7 +2,6 @@ from bracket.logic.scheduling.ladder_teams import get_possible_upcoming_matches_for_swiss from bracket.models.db.match import MatchFilter, SuggestedMatch -from bracket.models.db.round import Round from bracket.models.db.stage_item import StageType from bracket.models.db.util import RoundWithMatches, StageItemWithRounds from bracket.sql.rounds import get_rounds_for_stage_item @@ -29,17 +28,19 @@ async def get_draft_round_in_stage_item( return draft_round, stage_item -async def get_upcoming_matches_for_swiss_round( +async def get_upcoming_matches_for_swiss( match_filter: MatchFilter, stage_item: StageItemWithRounds, - round_: Round, tournament_id: TournamentId, + draft_round: RoundWithMatches | None = None, ) -> list[SuggestedMatch]: if stage_item.type is not StageType.SWISS: raise HTTPException(400, "Expected stage item to be of type SWISS.") - if not round_.is_draft: + if draft_round is not None and not draft_round.is_draft: raise HTTPException(400, "There is no draft round, so no matches can be scheduled.") rounds = await get_rounds_for_stage_item(tournament_id, stage_item.id) - return get_possible_upcoming_matches_for_swiss(match_filter, rounds, stage_item.inputs) + return get_possible_upcoming_matches_for_swiss( + match_filter, rounds, stage_item.inputs, draft_round + ) diff --git a/backend/bracket/models/db/player.py b/backend/bracket/models/db/player.py index 216d1047d..c841dc339 100644 --- a/backend/bracket/models/db/player.py +++ b/backend/bracket/models/db/player.py @@ -23,7 +23,7 @@ class Player(PlayerInsertable): id: PlayerId def __hash__(self) -> int: - return self.id if self.id is not None else int(self.created.timestamp()) + return self.id class PlayerBody(BaseModelORM): diff --git a/backend/bracket/models/db/round.py b/backend/bracket/models/db/round.py index 42ecd1356..d7365c189 100644 --- a/backend/bracket/models/db/round.py +++ b/backend/bracket/models/db/round.py @@ -8,7 +8,6 @@ class RoundInsertable(BaseModelORM): created: datetime_utc stage_item_id: StageItemId is_draft: bool - is_active: bool = False name: str @@ -19,7 +18,6 @@ class Round(RoundInsertable): class RoundUpdateBody(BaseModelORM): name: str is_draft: bool - is_active: bool class RoundCreateBody(BaseModelORM): diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 8d08384de..1b7d3b573 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from starlette import status from bracket.logic.planning.conflicts import handle_conflicts from bracket.logic.planning.matches import ( @@ -12,7 +13,7 @@ ) from bracket.logic.scheduling.upcoming_matches import ( get_draft_round_in_stage_item, - get_upcoming_matches_for_swiss_round, + get_upcoming_matches_for_swiss, ) from bracket.models.db.match import ( Match, @@ -21,7 +22,6 @@ MatchCreateBodyFrontend, MatchFilter, MatchRescheduleBody, - SuggestedMatch, ) from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament @@ -60,10 +60,13 @@ async def get_matches_to_schedule( ) draft_round, stage_item = await get_draft_round_in_stage_item(tournament_id, stage_item_id) + courts = await get_all_courts_in_tournament(tournament_id) + if len(courts) <= len(draft_round.matches): + return UpcomingMatchesResponse(data=[]) return UpcomingMatchesResponse( - data=await get_upcoming_matches_for_swiss_round( - match_filter, stage_item, draft_round, tournament_id + data=await get_upcoming_matches_for_swiss( + match_filter, stage_item, tournament_id, draft_round ) ) @@ -75,10 +78,15 @@ async def delete_match( match: Match = Depends(match_dependency), ) -> SuccessResponse: round_ = await get_round_by_id(tournament_id, match.round_id) + if not round_.is_draft: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can only delete matches from draft rounds", + ) await sql_delete_match(match.id) - await recalculate_ranking_for_stage_item_id(tournament_id, assert_some(round_).stage_item_id) + await recalculate_ranking_for_stage_item_id(tournament_id, round_.stage_item_id) return SuccessResponse() @@ -91,6 +99,13 @@ async def create_match( # TODO: check this is a swiss stage item await check_foreign_keys_belong_to_tournament(match_body, tournament_id) + round_ = await get_round_by_id(tournament_id, match_body.round_id) + if not round_.is_draft: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can only delete matches from draft rounds", + ) + tournament = await sql_get_tournament(tournament_id) body_with_durations = MatchCreateBody( **match_body.model_dump(), @@ -127,59 +142,6 @@ async def reschedule_match( return SuccessResponse() -@router.post( - "/tournaments/{tournament_id}/stage_items/{stage_item_id}/schedule_auto", - response_model=SuccessResponse, -) -async def create_matches_automatically( - tournament_id: TournamentId, - stage_item_id: StageItemId, - elo_diff_threshold: int = 100, - iterations: int = 200, - only_recommended: bool = False, - _: UserPublic = Depends(user_authenticated_for_tournament), -) -> SuccessResponse: - match_filter = MatchFilter( - elo_diff_threshold=elo_diff_threshold, - only_recommended=only_recommended, - limit=1, - iterations=iterations, - ) - - draft_round, stage_item = await get_draft_round_in_stage_item(tournament_id, stage_item_id) - courts = await get_all_courts_in_tournament(tournament_id) - tournament = await sql_get_tournament(tournament_id) - - limit = len(courts) - len(draft_round.matches) - for __ in range(limit): - all_matches_to_schedule = await get_upcoming_matches_for_swiss_round( - match_filter, stage_item, draft_round, tournament_id - ) - if len(all_matches_to_schedule) < 1: - break - - match = all_matches_to_schedule[0] - assert isinstance(match, SuggestedMatch) - - assert draft_round.id and match.stage_item_input1.id and match.stage_item_input2.id - await sql_create_match( - MatchCreateBody( - round_id=draft_round.id, - stage_item_input1_id=match.stage_item_input1.id, - stage_item_input2_id=match.stage_item_input2.id, - court_id=None, - stage_item_input1_winner_from_match_id=None, - stage_item_input2_winner_from_match_id=None, - duration_minutes=tournament.duration_minutes, - margin_minutes=tournament.margin_minutes, - custom_duration_minutes=None, - custom_margin_minutes=None, - ), - ) - - return SuccessResponse() - - @router.put("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse) async def update_match_by_id( tournament_id: TournamentId, @@ -194,7 +156,7 @@ async def update_match_by_id( await sql_update_match(match_id, match_body, tournament) round_ = await get_round_by_id(tournament_id, match.round_id) - await recalculate_ranking_for_stage_item_id(tournament_id, assert_some(round_).stage_item_id) + await recalculate_ranking_for_stage_item_id(tournament_id, round_.stage_item_id) if ( match_body.custom_duration_minutes != match.custom_duration_minutes diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py index 6192dc0cd..ea34b25b0 100644 --- a/backend/bracket/routes/models.py +++ b/backend/bracket/routes/models.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from bracket.logic.ranking.statistics import TeamStatistics +from bracket.logic.scheduling.handle_stage_activation import StageItemInputUpdate from bracket.models.db.club import Club from bracket.models.db.court import Court from bracket.models.db.match import Match, SuggestedMatch @@ -17,7 +17,7 @@ from bracket.models.db.user import UserPublic from bracket.models.db.util import StageWithStageItems from bracket.routes.auth import Token -from bracket.utils.id_types import StageId, StageItemId, StageItemInputId +from bracket.utils.id_types import StageId, StageItemId DataT = TypeVar("DataT") @@ -110,7 +110,5 @@ class StageItemInputOptionsResponse( pass -class StageRankingResponse( - DataResponse[dict[StageItemId, list[tuple[StageItemInputId, TeamStatistics]]]] -): +class StageRankingResponse(DataResponse[dict[StageItemId, list[StageItemInputUpdate]]]): pass diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 0649692b7..a7111d70e 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -93,7 +93,7 @@ async def create_round( ), ) - await set_round_active_or_draft(round_id, tournament_id, is_active=False, is_draft=True) + await set_round_active_or_draft(round_id, tournament_id, is_draft=True) return SuccessResponse() @@ -105,12 +105,9 @@ async def update_round_by_id( _: UserPublic = Depends(user_authenticated_for_tournament), __: Round = Depends(round_dependency), ) -> SuccessResponse: - await set_round_active_or_draft( - round_id, tournament_id, is_active=round_body.is_active, is_draft=round_body.is_draft - ) query = """ UPDATE rounds - SET name = :name + SET name = :name, is_draft = :is_draft WHERE rounds.id IN ( SELECT rounds.id FROM rounds @@ -122,6 +119,11 @@ async def update_round_by_id( """ await database.execute( query=query, - values={"tournament_id": tournament_id, "round_id": round_id, "name": round_body.name}, + values={ + "tournament_id": tournament_id, + "round_id": round_id, + "name": round_body.name, + "is_draft": round_body.is_draft, + }, ) return SuccessResponse() diff --git a/backend/bracket/routes/stage_items.py b/backend/bracket/routes/stage_items.py index 48b838aaf..b9e4bea4c 100644 --- a/backend/bracket/routes/stage_items.py +++ b/backend/bracket/routes/stage_items.py @@ -1,16 +1,20 @@ from fastapi import APIRouter, Depends, HTTPException +from heliclockter import datetime_utc from starlette import status from bracket.database import database from bracket.logic.planning.rounds import ( MatchTimingAdjustmentInfeasible, - get_active_and_next_rounds, + get_draft_round, schedule_all_matches_for_swiss_round, ) from bracket.logic.scheduling.builder import ( build_matches_for_stage_item, ) +from bracket.logic.scheduling.upcoming_matches import get_upcoming_matches_for_swiss from bracket.logic.subscriptions import check_requirement +from bracket.models.db.match import MatchCreateBody, MatchFilter, SuggestedMatch +from bracket.models.db.round import RoundInsertable from bracket.models.db.stage_item import ( StageItemActivateNextBody, StageItemCreateBody, @@ -23,12 +27,20 @@ ) from bracket.routes.models import SuccessResponse from bracket.routes.util import stage_item_dependency -from bracket.sql.rounds import set_round_active_or_draft +from bracket.sql.courts import get_all_courts_in_tournament +from bracket.sql.matches import sql_create_match +from bracket.sql.rounds import ( + get_next_round_name, + get_round_by_id, + set_round_active_or_draft, + sql_create_round, +) from bracket.sql.shared import sql_delete_stage_item_with_foreign_keys from bracket.sql.stage_items import ( sql_create_stage_item_with_empty_inputs, ) from bracket.sql.stages import get_full_tournament_details +from bracket.sql.tournaments import sql_get_tournament from bracket.sql.validation import check_foreign_keys_belong_to_tournament from bracket.utils.errors import ( ForeignKey, @@ -109,21 +121,85 @@ async def start_next_round( stage_item_id: StageItemId, active_next_body: StageItemActivateNextBody, stage_item: StageItemWithRounds = Depends(stage_item_dependency), - _: UserPublic = Depends(user_authenticated_for_tournament), + user: UserPublic = Depends(user_authenticated_for_tournament), + elo_diff_threshold: int = 100, + iterations: int = 200, + only_recommended: bool = False, ) -> SuccessResponse: - __, next_round = get_active_and_next_rounds(stage_item) - if next_round is None: + draft_round = get_draft_round(stage_item) + if draft_round is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="There is already a draft round in this stage item, please delete it first", + ) + + match_filter = MatchFilter( + elo_diff_threshold=elo_diff_threshold, + only_recommended=only_recommended, + limit=1, + iterations=iterations, + ) + all_matches_to_schedule = await get_upcoming_matches_for_swiss( + match_filter, stage_item, tournament_id + ) + if len(all_matches_to_schedule) < 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=( - "There is no next round in this stage item, please create one " - "(after the current active round if there is an active round)" + detail="No more matches to schedule, all combinations of teams have been added already", + ) + + stages = await get_full_tournament_details(tournament_id) + existing_rounds = [ + round_ + for stage in stages + for stage_item in stage.stage_items + for round_ in stage_item.rounds + ] + check_requirement(existing_rounds, user, "max_rounds") + + round_id = await sql_create_round( + RoundInsertable( + created=datetime_utc.now(), + is_draft=True, + stage_item_id=stage_item_id, + name=await get_next_round_name(tournament_id, stage_item_id), + ), + ) + draft_round = await get_round_by_id(tournament_id, round_id) + tournament = await sql_get_tournament(tournament_id) + courts = await get_all_courts_in_tournament(tournament_id) + + limit = len(courts) - len(draft_round.matches) + for ___ in range(limit): + all_matches_to_schedule = await get_upcoming_matches_for_swiss( + match_filter, stage_item, tournament_id + ) + if len(all_matches_to_schedule) < 1: + break + + match = all_matches_to_schedule[0] + assert isinstance(match, SuggestedMatch) + + assert draft_round.id and match.stage_item_input1.id and match.stage_item_input2.id + await sql_create_match( + MatchCreateBody( + round_id=draft_round.id, + stage_item_input1_id=match.stage_item_input1.id, + stage_item_input2_id=match.stage_item_input2.id, + court_id=None, + stage_item_input1_winner_from_match_id=None, + stage_item_input2_winner_from_match_id=None, + duration_minutes=tournament.duration_minutes, + margin_minutes=tournament.margin_minutes, + custom_duration_minutes=None, + custom_margin_minutes=None, ), ) + draft_round = await get_round_by_id(tournament_id, round_id) try: await schedule_all_matches_for_swiss_round( - tournament_id, next_round, active_next_body.adjust_to_time + tournament_id, draft_round, active_next_body.adjust_to_time ) except MatchTimingAdjustmentInfeasible as exc: raise HTTPException( @@ -131,7 +207,6 @@ async def start_next_round( detail=str(exc), ) from exc - assert next_round.id is not None - await set_round_active_or_draft(next_round.id, tournament_id, is_active=True, is_draft=False) + await set_round_active_or_draft(draft_round.id, tournament_id, is_draft=False) return SuccessResponse() diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py index eb18b42e9..2ea3b0ac9 100644 --- a/backend/bracket/routes/stages.py +++ b/backend/bracket/routes/stages.py @@ -4,10 +4,9 @@ from bracket.database import database from bracket.logic.scheduling.builder import determine_available_inputs from bracket.logic.scheduling.handle_stage_activation import ( - get_team_rankings_lookup_for_stage, + get_updates_to_inputs_in_activated_stage, update_matches_in_activated_stage, update_matches_in_deactivated_stage, - # update_matches_in_activated_stage, ) from bracket.logic.subscriptions import check_requirement from bracket.models.db.stage import Stage, StageActivateBody, StageUpdateBody @@ -152,15 +151,19 @@ async def get_available_inputs( return StageItemInputOptionsResponse(data=available_inputs) -@router.get("/tournaments/{tournament_id}/stages/{stage_id}/rankings") -async def get_rankings( +@router.get("/tournaments/{tournament_id}/next_stage_rankings") +async def get_next_stage_rankings( tournament_id: TournamentId, - stage_id: StageId, _: UserPublic = Depends(user_authenticated_for_tournament), - __: Stage = Depends(stage_dependency), ) -> StageRankingResponse: """ Get the rankings for the stage items in this stage. """ - [stage] = await get_full_tournament_details(tournament_id, stage_id=stage_id) - return StageRankingResponse(data=await get_team_rankings_lookup_for_stage(tournament_id, stage)) + next_stage_id = await get_next_stage_in_tournament(tournament_id, "next") + + if next_stage_id is None: + return StageRankingResponse(data={}) + + return StageRankingResponse( + data=await get_updates_to_inputs_in_activated_stage(tournament_id, next_stage_id) + ) diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py index fe52dc699..d3bd452a3 100644 --- a/backend/bracket/routes/util.py +++ b/backend/bracket/routes/util.py @@ -34,14 +34,7 @@ async def round_dependency(tournament_id: TournamentId, round_id: RoundId) -> Ro async def round_with_matches_dependency( tournament_id: TournamentId, round_id: RoundId ) -> RoundWithMatches: - round_ = await get_round_by_id(tournament_id, round_id) - if round_ is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Could not find round with id {round_id}", - ) - - return round_ + return await get_round_by_id(tournament_id, round_id) async def stage_dependency(tournament_id: TournamentId, stage_id: StageId) -> StageWithStageItems: diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 18b913bbe..f2b1d2949 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -93,7 +93,6 @@ Column("name", Text, nullable=False), Column("created", DateTimeTZ, nullable=False, server_default=func.now()), Column("is_draft", Boolean, nullable=False), - Column("is_active", Boolean, nullable=False, server_default="false"), Column("stage_item_id", BigInteger, ForeignKey("stage_items.id"), nullable=False), ) diff --git a/backend/bracket/sql/rounds.py b/backend/bracket/sql/rounds.py index d79052593..68b7491c6 100644 --- a/backend/bracket/sql/rounds.py +++ b/backend/bracket/sql/rounds.py @@ -8,8 +8,8 @@ async def sql_create_round(round_: RoundInsertable) -> RoundId: query = """ - INSERT INTO rounds (created, is_draft, is_active, name, stage_item_id) - VALUES (NOW(), :is_draft, :is_active, :name, :stage_item_id) + INSERT INTO rounds (created, is_draft, name, stage_item_id) + VALUES (NOW(), :is_draft, :name, :stage_item_id) RETURNING id """ result: RoundId = await database.fetch_val( @@ -17,7 +17,6 @@ async def sql_create_round(round_: RoundInsertable) -> RoundId: values={ "name": round_.name, "is_draft": round_.is_draft, - "is_active": round_.is_active, "stage_item_id": round_.stage_item_id, }, ) @@ -37,9 +36,7 @@ async def get_rounds_for_stage_item( return stage_item.rounds -async def get_round_by_id( - tournament_id: TournamentId, round_id: RoundId -) -> RoundWithMatches | None: +async def get_round_by_id(tournament_id: TournamentId, round_id: RoundId) -> RoundWithMatches: stages = await get_full_tournament_details( tournament_id, no_draft_rounds=False, round_id=round_id ) @@ -50,7 +47,7 @@ async def get_round_by_id( if round_ is not None: return round_ - return None + raise ValueError(f"Could not find round with id {round_id} for tournament {tournament_id}") async def get_next_round_name(tournament_id: TournamentId, stage_item_id: StageItemId) -> str: @@ -78,7 +75,7 @@ async def sql_delete_rounds_for_stage_item_id(stage_item_id: StageItemId) -> Non async def set_round_active_or_draft( - round_id: RoundId, tournament_id: TournamentId, *, is_active: bool, is_draft: bool + round_id: RoundId, tournament_id: TournamentId, *, is_draft: bool ) -> None: query = """ UPDATE rounds @@ -86,10 +83,6 @@ async def set_round_active_or_draft( is_draft = CASE WHEN rounds.id=:round_id THEN :is_draft ELSE is_draft AND NOT :is_draft - END, - is_active = - CASE WHEN rounds.id=:round_id THEN :is_active - ELSE is_active AND NOT :is_active END WHERE rounds.id IN ( SELECT rounds.id @@ -104,7 +97,6 @@ async def set_round_active_or_draft( values={ "tournament_id": tournament_id, "round_id": round_id, - "is_active": is_active, "is_draft": is_draft, }, ) diff --git a/backend/tests/integration_tests/api/auto_scheduling_matches_test.py b/backend/tests/integration_tests/api/auto_scheduling_matches_test.py index ba5034f7e..28bbfdf3c 100644 --- a/backend/tests/integration_tests/api/auto_scheduling_matches_test.py +++ b/backend/tests/integration_tests/api/auto_scheduling_matches_test.py @@ -5,10 +5,9 @@ from bracket.models.db.stage_item_inputs import ( StageItemInputCreateBodyFinal, ) -from bracket.sql.rounds import get_round_by_id, sql_create_round +from bracket.sql.rounds import sql_create_round from bracket.sql.shared import sql_delete_stage_item_with_foreign_keys from bracket.sql.stage_items import sql_create_stage_item_with_inputs -from bracket.sql.stages import get_full_tournament_details from bracket.utils.dummy_records import ( DUMMY_COURT1, DUMMY_STAGE2, @@ -16,7 +15,6 @@ DUMMY_TEAM1, ) from bracket.utils.http import HTTPMethod -from bracket.utils.types import assert_some from tests.integration_tests.api.shared import ( SUCCESS_RESPONSE, send_tournament_request, @@ -30,69 +28,6 @@ ) -async def test_schedule_matches_auto( - startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext -) -> None: - async with ( - inserted_court( - DUMMY_COURT1.model_copy(update={"tournament_id": auth_context.tournament.id}) - ), - inserted_stage( - DUMMY_STAGE2.model_copy(update={"tournament_id": auth_context.tournament.id}) - ) as stage_inserted_1, - inserted_team( - DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id}) - ) as team_inserted_1, - inserted_team( - DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id}) - ) as team_inserted_2, - ): - tournament_id = auth_context.tournament.id - stage_item_1 = await sql_create_stage_item_with_inputs( - tournament_id, - StageItemWithInputsCreate( - stage_id=stage_inserted_1.id, - name=DUMMY_STAGE_ITEM1.name, - team_count=2, - type=StageType.SWISS, - inputs=[ - StageItemInputCreateBodyFinal( - slot=1, - team_id=team_inserted_1.id, - ), - StageItemInputCreateBodyFinal( - slot=2, - team_id=team_inserted_2.id, - ), - ], - ), - ) - await sql_create_round( - RoundInsertable( - stage_item_id=stage_item_1.id, - name="", - is_draft=True, - is_active=False, - created=MOCK_NOW, - ), - ) - - response = await send_tournament_request( - HTTPMethod.POST, - f"stage_items/{stage_item_1.id}/schedule_auto", - auth_context, - ) - stages = await get_full_tournament_details(tournament_id) - - await sql_delete_stage_item_with_foreign_keys(stage_item_1.id) - - assert response == SUCCESS_RESPONSE - - stage_item = stages[0].stage_items[0] - assert len(stage_item.rounds) == 1 - assert len(stage_item.rounds[0].matches) == 1 - - async def test_start_next_round( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: @@ -130,21 +65,11 @@ async def test_start_next_round( ], ), ) - round_1_id = await sql_create_round( - RoundInsertable( - stage_item_id=stage_item_1.id, - name="", - is_draft=True, - is_active=False, - created=MOCK_NOW, - ), - ) - round_2_id = await sql_create_round( + await sql_create_round( RoundInsertable( stage_item_id=stage_item_1.id, name="", - is_draft=True, - is_active=False, + is_draft=False, created=MOCK_NOW, ), ) @@ -158,8 +83,6 @@ async def test_start_next_round( ) assert response == SUCCESS_RESPONSE - round_1 = await get_round_by_id(tournament_id, round_1_id) - assert assert_some(round_1).is_active response = await send_tournament_request( HTTPMethod.POST, @@ -167,8 +90,7 @@ async def test_start_next_round( auth_context, json={"adjust_to_time": datetime_utc.now().isoformat()}, ) - assert response == SUCCESS_RESPONSE - round_2 = await get_round_by_id(tournament_id, round_2_id) - assert assert_some(round_2).is_active + msg = "No more matches to schedule, all combinations of teams have been added already" + assert response == {"detail": msg} finally: await sql_delete_stage_item_with_foreign_keys(stage_item_1.id) diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 10049f05b..8e581a861 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -50,7 +50,9 @@ async def test_create_match( ) ) as stage_item_inserted, inserted_round( - DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id}) + DUMMY_ROUND1.model_copy( + update={"stage_item_id": stage_item_inserted.id, "is_draft": True} + ) ) as round_inserted, inserted_team( DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id}) @@ -71,7 +73,7 @@ async def test_create_match( response = await send_tournament_request( HTTPMethod.POST, "matches", auth_context, json=body ) - assert response["data"]["id"] + assert response["data"]["id"], response await assert_row_count_and_clear(matches, 1) @@ -89,7 +91,9 @@ async def test_delete_match( ) ) as stage_item_inserted, inserted_round( - DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id}) + DUMMY_ROUND1.model_copy( + update={"stage_item_id": stage_item_inserted.id, "is_draft": True} + ) ) as round_inserted, inserted_team( DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id}) @@ -315,6 +319,9 @@ async def test_upcoming_matches_endpoint( } ) ) as stage_item_inserted, + inserted_court( + DUMMY_COURT1.model_copy(update={"tournament_id": auth_context.tournament.id}) + ), inserted_round( DUMMY_ROUND1.model_copy( update={ diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index 6b6342480..71887e1c6 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -75,7 +75,7 @@ async def test_delete_round( async def test_update_round( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - body = {"name": "Some new name", "is_draft": True, "is_active": False} + body = {"name": "Some new name", "is_draft": True} async with ( inserted_team(DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id})), inserted_stage( @@ -101,6 +101,5 @@ async def test_update_round( ) assert updated_round.name == body["name"] assert updated_round.is_draft == body["is_draft"] - assert updated_round.is_active == body["is_active"] await assert_row_count_and_clear(rounds, 1) diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py index 217a1de71..dfc25cd3a 100644 --- a/backend/tests/integration_tests/api/stages_test.py +++ b/backend/tests/integration_tests/api/stages_test.py @@ -74,7 +74,6 @@ async def test_stages_endpoint( "stage_item_id": stage_item_inserted.id, "created": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"), "is_draft": False, - "is_active": False, "name": "Round 1", "matches": [], } @@ -172,15 +171,12 @@ async def test_activate_stage( ) == SUCCESS_RESPONSE ) - [prev_stage, next_stage] = await get_full_tournament_details(auth_context.tournament.id) - assert prev_stage.is_active is False - assert next_stage.is_active is True await assert_row_count_and_clear(stage_items, 1) await assert_row_count_and_clear(stages, 1) -async def test_get_rankings( +async def test_get_next_stage_rankings( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: async with ( @@ -192,10 +188,10 @@ async def test_get_rankings( DUMMY_STAGE_ITEM1.model_copy( update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id} ) - ) as stage_item_inserted, + ), ): response = await send_tournament_request( - HTTPMethod.GET, f"stages/{stage_inserted.id}/rankings", auth_context + HTTPMethod.GET, "next_stage_rankings", auth_context ) - assert response == {"data": {f"{stage_item_inserted.id}": []}} + assert response == {"data": {}} diff --git a/backend/tests/unit_tests/swiss_test.py b/backend/tests/unit_tests/swiss_test.py index fe6908918..10143261c 100644 --- a/backend/tests/unit_tests/swiss_test.py +++ b/backend/tests/unit_tests/swiss_test.py @@ -1,8 +1,5 @@ from decimal import Decimal -import pytest -from fastapi import HTTPException - from bracket.logic.scheduling.ladder_teams import get_possible_upcoming_matches_for_swiss from bracket.models.db.match import Match, MatchFilter, MatchWithDetailsDefinitive, SuggestedMatch from bracket.models.db.stage_item_inputs import ( @@ -28,11 +25,6 @@ MATCH_FILTER = MatchFilter(elo_diff_threshold=50, iterations=100, limit=20, only_recommended=False) -def test_no_draft_round() -> None: - with pytest.raises(HTTPException, match="There is no draft round, so no matches can be"): - get_possible_upcoming_matches_for_swiss(MATCH_FILTER, [], []) - - def get_match( match: Match, stage_item_input1: StageItemInputFinal, stage_item_input2: StageItemInputFinal ) -> MatchWithDetailsDefinitive: diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index fb664596f..4fe3d877e 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -29,11 +29,13 @@ "adjust_start_times_checkbox_label": "Adjust start time of matches in this round to the current time", "all_matches_radio_label": "All matches", "api_docs_title": "API docs", + "mark_round_as_non_draft": "Mark this round as ready", + "mark_round_as_draft": "Mark this round as draft", "at_least_one_player_validation": "Enter at least one player", "at_least_one_team_validation": "Enter at least one team", "at_least_two_team_validation": "Need at least two teams", "auto_assign_courts_label": "Automatically assign courts to matches", - "auto_create_matches_button": "Add new matches automatically", + "auto_create_matches_button": "Plan new round automatically", "courts_filled_badge": "courts filled", "back_home_nav": "Take me back to home page", "back_to_login_nav": "Back to login page", diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx index c4472bbb5..a8e7bf33e 100644 --- a/frontend/src/components/brackets/brackets.tsx +++ b/frontend/src/components/brackets/brackets.tsx @@ -3,10 +3,8 @@ import { Button, Center, Container, - Divider, Grid, Group, - Progress, Skeleton, Stack, Switch, @@ -20,12 +18,10 @@ import { MdOutlineAutoFixHigh } from 'react-icons/md'; import { SWRResponse } from 'swr'; import { BracketDisplaySettings } from '../../interfaces/brackets'; -import { SchedulerSettings } from '../../interfaces/match'; import { RoundInterface } from '../../interfaces/round'; -import { StageItemWithRounds, stageItemIsHandledAutomatically } from '../../interfaces/stage_item'; +import { StageItemWithRounds } from '../../interfaces/stage_item'; import { Tournament, TournamentMinimal } from '../../interfaces/tournament'; import { createRound } from '../../services/round'; -import { AutoCreateMatchesButton } from '../buttons/create_matches_auto'; import ActivateNextRoundModal from '../modals/activate_next_round_modal'; import { NoContent } from '../no_content/empty_table_info'; import { Translator } from '../utils/types'; @@ -37,12 +33,14 @@ function AddRoundButton({ tournamentData, stageItem, swrStagesResponse, + swrUpcomingMatchesResponse, size, }: { t: Translator; tournamentData: TournamentMinimal; stageItem: StageItemWithRounds; swrStagesResponse: SWRResponse; + swrUpcomingMatchesResponse: SWRResponse; size: 'md' | 'lg'; }) { return ( @@ -54,6 +52,7 @@ function AddRoundButton({ onClick={async () => { await createRound(tournamentData.id, stageItem.id); await swrStagesResponse.mutate(); + await swrUpcomingMatchesResponse.mutate(); }} > {t('add_round_button')} @@ -65,22 +64,16 @@ export function RoundsGridCols({ stageItem, tournamentData, swrStagesResponse, - swrCourtsResponse, swrUpcomingMatchesResponse, - schedulerSettings, readOnly, displaySettings, - draftRound, }: { stageItem: StageItemWithRounds; tournamentData: Tournament; swrStagesResponse: SWRResponse; - swrCourtsResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse; - schedulerSettings: SchedulerSettings; readOnly: boolean; displaySettings: BracketDisplaySettings; - draftRound: RoundInterface; }) { const { t } = useTranslation(); @@ -101,7 +94,6 @@ export function RoundsGridCols({ swrStagesResponse={swrStagesResponse} swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} readOnly={readOnly} - dynamicSchedule={!stageItemIsHandledAutomatically(stageItem)} displaySettings={displaySettings} /> )); @@ -118,6 +110,7 @@ export function RoundsGridCols({ tournamentData={tournamentData} stageItem={stageItem} swrStagesResponse={swrStagesResponse} + swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} size="lg" /> @@ -125,11 +118,7 @@ export function RoundsGridCols({ ); } - const hideAddRoundButton = - tournamentData == null || readOnly || stageItemIsHandledAutomatically(stageItem); - - const courtsCount = swrCourtsResponse.data?.data?.length || 0; - const scheduledMatchesCount = draftRound?.matches.length; + const hideAddRoundButton = tournamentData == null || readOnly; return ( @@ -138,22 +127,6 @@ export function RoundsGridCols({
- {scheduledMatchesCount == null ? null : ( - <> - - <> - {scheduledMatchesCount} / {courtsCount} {t('courts_filled_badge')} - - - - - - )} } @@ -170,33 +143,28 @@ export function RoundsGridCols({ }} miw="9rem" /> - -
- {hideAddRoundButton ? null : ( + {hideAddRoundButton || + displaySettings.showManualSchedulingOptions === 'false' ? null : ( )} - {hideAddRoundButton ? null : ( + {hideAddRoundButton || + displaySettings.showManualSchedulingOptions === 'true' ? null : ( )} diff --git a/frontend/src/components/brackets/courts.tsx b/frontend/src/components/brackets/courts.tsx index 0965721e6..cf4cda47d 100644 --- a/frontend/src/components/brackets/courts.tsx +++ b/frontend/src/components/brackets/courts.tsx @@ -24,8 +24,8 @@ function getRoundsGridCols( swrStagesResponse={swrStagesResponse} swrUpcomingMatchesResponse={null} match={match} + round={activeRound} readOnly - dynamicSchedule={false} /> )); diff --git a/frontend/src/components/brackets/match.tsx b/frontend/src/components/brackets/match.tsx index 45be690ee..c88e10150 100644 --- a/frontend/src/components/brackets/match.tsx +++ b/frontend/src/components/brackets/match.tsx @@ -1,4 +1,5 @@ import { Center, Grid, UnstyledButton, useMantineTheme } from '@mantine/core'; +import { useColorScheme } from '@mantine/hooks'; import assert from 'assert'; import React, { useState } from 'react'; import { SWRResponse } from 'swr'; @@ -9,6 +10,7 @@ import { formatMatchInput2, isMatchHappening, } from '../../interfaces/match'; +import { RoundInterface } from '../../interfaces/round'; import { TournamentMinimal } from '../../interfaces/tournament'; import { getMatchLookup, getStageItemLookup } from '../../services/lookups'; import MatchModal from '../modals/match_modal'; @@ -17,7 +19,7 @@ import classes from './match.module.css'; export function MatchBadge({ match, theme }: { match: MatchInterface; theme: any }) { const visibility = match.court ? 'visible' : 'hidden'; - const badgeColor = theme.colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[7]; + const badgeColor = useColorScheme() ? theme.colors.blue[7] : theme.colors.blue[7]; return (
); diff --git a/frontend/src/components/brackets/round.tsx b/frontend/src/components/brackets/round.tsx index 015c8e2ba..12d811195 100644 --- a/frontend/src/components/brackets/round.tsx +++ b/frontend/src/components/brackets/round.tsx @@ -19,7 +19,6 @@ export default function Round({ swrStagesResponse, swrUpcomingMatchesResponse, readOnly, - dynamicSchedule, displaySettings, }: { tournamentData: TournamentMinimal; @@ -27,7 +26,6 @@ export default function Round({ swrStagesResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse | null; readOnly: boolean; - dynamicSchedule: boolean; displaySettings: BracketDisplaySettings; }) { const matches = round.matches @@ -48,23 +46,18 @@ export default function Round({ swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} match={match} readOnly={readOnly} - dynamicSchedule={dynamicSchedule} + round={round} /> )); - const active_round_style = round.is_active + const active_round_style = round.is_draft ? { - borderStyle: 'solid', - borderColor: 'green', + borderStyle: 'dashed', + borderColor: 'gray', } - : round.is_draft - ? { - borderStyle: 'dashed', - borderColor: 'gray', - } - : { - borderStyle: 'solid', - borderColor: 'gray', - }; + : { + borderStyle: 'solid', + borderColor: 'gray', + }; const modal = readOnly ? ( {round.name} @@ -72,9 +65,8 @@ export default function Round({ ); diff --git a/frontend/src/components/builder/builder.tsx b/frontend/src/components/builder/builder.tsx index 53f1e49ad..7bf807507 100644 --- a/frontend/src/components/builder/builder.tsx +++ b/frontend/src/components/builder/builder.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Badge, Card, + CheckIcon, Combobox, Group, InputBase, @@ -10,9 +11,9 @@ import { Text, Tooltip, useCombobox, - useMantineColorScheme, useMantineTheme, } from '@mantine/core'; +import { useColorScheme } from '@mantine/hooks'; import { AiFillWarning } from '@react-icons/all-files/ai/AiFillWarning'; import { BiCheck } from '@react-icons/all-files/bi/BiCheck'; import { IconDots, IconPencil, IconTrash } from '@tabler/icons-react'; @@ -30,7 +31,7 @@ import { StageItemInput, StageItemInputChoice, StageItemInputOption, - formatStageItemInput, + formatStageItemInputTentative, } from '../../interfaces/stage_item_input'; import { Tournament } from '../../interfaces/tournament'; import { getStageItemLookup, getTeamsLookup } from '../../services/lookups'; @@ -50,6 +51,7 @@ function StageItemInputComboBox({ current_key, availableInputs, swrAvailableInputsResponse, + swrRankingsPerStageItemResponse, swrStagesResponse, }: { tournament: Tournament; @@ -57,6 +59,7 @@ function StageItemInputComboBox({ current_key: string | null; availableInputs: StageItemInputChoice[]; swrAvailableInputsResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; swrStagesResponse: SWRResponse; }) { const { t } = useTranslation(); @@ -82,13 +85,15 @@ function StageItemInputComboBox({ .filter((item) => (item.label || 'None').toLowerCase().includes(search.toLowerCase().trim())) .map((option: StageItemInputChoice, i: number) => ( - {option.label || None} + + {option.label || None} + {option.value === selectedInput?.value && } + )); const theme = useMantineTheme(); - const scheme = useMantineColorScheme(); - const dropdownBorderColor = scheme.colorScheme === 'dark' ? '#444' : '#ccc'; + const dropdownBorderColor = useColorScheme() === 'dark' ? '#444' : '#ccc'; return ( { swrAvailableInputsResponse.mutate(); swrStagesResponse.mutate(); + swrRankingsPerStageItemResponse.mutate(); setSuccessIcon(true); @@ -163,7 +169,7 @@ export function getAvailableInputs( if (stageItem == null) return null; return { value: `${option.winner_from_stage_item_id}_${option.winner_position}`, - label: `${formatStageItemInput(option.winner_position, stageItem.name)}`, + label: `${formatStageItemInputTentative(option, stageItemMap)}`, team_id: null, winner_from_stage_item_id: option.winner_from_stage_item_id, winner_position: option.winner_position, @@ -203,6 +209,7 @@ function StageItemInputSection({ availableInputs, swrAvailableInputsResponse, swrStagesResponse, + swrRankingsPerStageItemResponse, }: { tournament: Tournament; stageItemInput: StageItemInput; @@ -211,6 +218,7 @@ function StageItemInputSection({ availableInputs: StageItemInputChoice[]; swrAvailableInputsResponse: SWRResponse; swrStagesResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; }) { const opts = lastInList ? { pt: 'xs', mb: '-0.5rem' } : { py: 'xs', withBorder: true }; @@ -222,6 +230,7 @@ function StageItemInputSection({ current_key={currentOptionValue} availableInputs={availableInputs} swrAvailableInputsResponse={swrAvailableInputsResponse} + swrRankingsPerStageItemResponse={swrRankingsPerStageItemResponse} swrStagesResponse={swrStagesResponse} /> @@ -235,6 +244,7 @@ function StageItemRow({ availableInputs, rankings, swrAvailableInputsResponse, + swrRankingsPerStageItemResponse, }: { tournament: Tournament; stageItem: StageItemWithRounds; @@ -242,6 +252,7 @@ function StageItemRow({ availableInputs: StageItemInputChoice[]; rankings: Ranking[]; swrAvailableInputsResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; }) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); @@ -266,6 +277,7 @@ function StageItemRow({ lastInList={i === stageItem.inputs.length - 1} swrAvailableInputsResponse={swrAvailableInputsResponse} swrStagesResponse={swrStagesResponse} + swrRankingsPerStageItemResponse={swrRankingsPerStageItemResponse} /> ); }); @@ -284,16 +296,18 @@ function StageItemRow({ rankings={rankings} /> - - - - - + {stageItem.type === 'SWISS' ? ( + + + + + + ) : null} @@ -314,7 +328,7 @@ function StageItemRow({ } component={Link} - href={`/tournaments/${tournament.id}/swiss/${stageItem.id}`} + href={`/tournaments/${tournament.id}/stages/swiss/${stageItem.id}`} > {t('handle_swiss_system')} @@ -345,12 +359,14 @@ function StageColumn({ stage, swrStagesResponse, swrAvailableInputsResponse, + swrRankingsPerStageItemResponse, rankings, }: { tournament: Tournament; stage: StageWithStageItems; swrStagesResponse: SWRResponse; swrAvailableInputsResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; rankings: Ranking[]; }) { const { t } = useTranslation(); @@ -384,6 +400,7 @@ function StageColumn({ swrStagesResponse={swrStagesResponse} availableInputs={availableInputs} swrAvailableInputsResponse={swrAvailableInputsResponse} + swrRankingsPerStageItemResponse={swrRankingsPerStageItemResponse} rankings={rankings} /> )); @@ -448,11 +465,13 @@ export default function Builder({ tournament, swrStagesResponse, swrAvailableInputsResponse, + swrRankingsPerStageItemResponse, rankings, }: { tournament: Tournament; swrStagesResponse: SWRResponse; swrAvailableInputsResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; rankings: Ranking[]; }) { const stages: StageWithStageItems[] = @@ -471,6 +490,7 @@ export default function Builder({ tournament={tournament} swrStagesResponse={swrStagesResponse} swrAvailableInputsResponse={swrAvailableInputsResponse} + swrRankingsPerStageItemResponse={swrRankingsPerStageItemResponse} stage={stage} rankings={rankings} /> @@ -483,6 +503,7 @@ export default function Builder({ tournament={tournament} swrStagesResponse={swrStagesResponse} swrAvailableInputsResponse={swrAvailableInputsResponse} + swrRankingsPerStageItemResponse={swrRankingsPerStageItemResponse} /> diff --git a/frontend/src/components/buttons/create_matches_auto.tsx b/frontend/src/components/buttons/create_matches_auto.tsx deleted file mode 100644 index e927d4660..000000000 --- a/frontend/src/components/buttons/create_matches_auto.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Button } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import React from 'react'; -import { MdOutlineAutoFixHigh } from 'react-icons/md'; -import { SWRResponse } from 'swr'; - -import { BracketDisplaySettings } from '../../interfaces/brackets'; -import { SchedulerSettings } from '../../interfaces/match'; -import { Tournament } from '../../interfaces/tournament'; -import { createMatchesAuto } from '../../services/round'; - -export function AutoCreateMatchesButton({ - tournamentData, - swrStagesResponse, - swrUpcomingMatchesResponse, - stageItemId, - schedulerSettings, - displaySettings, -}: { - schedulerSettings: SchedulerSettings; - stageItemId: number; - tournamentData: Tournament; - swrStagesResponse: SWRResponse; - swrUpcomingMatchesResponse: SWRResponse; - displaySettings: BracketDisplaySettings; -}) { - const { t } = useTranslation(); - return ( - - ); -} diff --git a/frontend/src/components/buttons/create_stage.tsx b/frontend/src/components/buttons/create_stage.tsx index fa04377e3..4e137ddd6 100644 --- a/frontend/src/components/buttons/create_stage.tsx +++ b/frontend/src/components/buttons/create_stage.tsx @@ -11,10 +11,12 @@ export default function CreateStageButton({ tournament, swrStagesResponse, swrAvailableInputsResponse, + swrRankingsPerStageItemResponse, }: { tournament: Tournament; swrStagesResponse: SWRResponse; swrAvailableInputsResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; }) { const { t } = useTranslation(); @@ -28,6 +30,7 @@ export default function CreateStageButton({ await createStage(tournament.id); await swrStagesResponse.mutate(); await swrAvailableInputsResponse.mutate(); + await swrRankingsPerStageItemResponse.mutate(); }} leftSection={} > diff --git a/frontend/src/components/modals/activate_next_round_modal.tsx b/frontend/src/components/modals/activate_next_round_modal.tsx index d796a221b..39612eebc 100644 --- a/frontend/src/components/modals/activate_next_round_modal.tsx +++ b/frontend/src/components/modals/activate_next_round_modal.tsx @@ -3,6 +3,7 @@ import { useForm } from '@mantine/form'; import { IconAlertCircle, IconSquareArrowRight } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import React, { useState } from 'react'; +import { MdOutlineAutoFixHigh } from 'react-icons/md'; import { SWRResponse } from 'swr'; import { StageItemWithRounds } from '../../interfaces/stage_item'; @@ -12,10 +13,12 @@ export default function ActivateNextRoundModal({ tournamentId, stageItem, swrStagesResponse, + swrUpcomingMatchesResponse, }: { tournamentId: number; stageItem: StageItemWithRounds; swrStagesResponse: SWRResponse; + swrUpcomingMatchesResponse: SWRResponse; }) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); @@ -42,6 +45,7 @@ export default function ActivateNextRoundModal({ values.adjust_to_time ? new Date() : null ); await swrStagesResponse.mutate(); + await swrUpcomingMatchesResponse.mutate(); setOpened(false); })} > @@ -75,7 +79,7 @@ export default function ActivateNextRoundModal({ type="submit" leftSection={} > - {t('plan_next_round_button')} + {t('auto_create_matches_button')} @@ -83,10 +87,11 @@ export default function ActivateNextRoundModal({ ); diff --git a/frontend/src/components/modals/activate_next_stage_modal.tsx b/frontend/src/components/modals/activate_next_stage_modal.tsx index 9478813e8..f0247066d 100644 --- a/frontend/src/components/modals/activate_next_stage_modal.tsx +++ b/frontend/src/components/modals/activate_next_stage_modal.tsx @@ -1,21 +1,87 @@ -import { Alert, Button, Modal } from '@mantine/core'; +import { Alert, Button, Container, Grid, Loader, Modal, Title } from '@mantine/core'; import { useForm } from '@mantine/form'; +import { FaArrowRight } from '@react-icons/all-files/fa/FaArrowRight'; import { IconAlertCircle, IconSquareArrowRight } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import React, { useState } from 'react'; import { SWRResponse } from 'swr'; +import { StageItemWithRounds } from '../../interfaces/stage_item'; +import { StageItemInput, formatStageItemInput } from '../../interfaces/stage_item_input'; +import { TeamInterface } from '../../interfaces/team'; +import { getStageItemLookup } from '../../services/lookups'; import { activateNextStage } from '../../services/stage'; +type Update = { stage_item_input: StageItemInput; team: TeamInterface }; +type StageItemUpdate = { updates: Update[]; stageItem: StageItemWithRounds }; + +function UpdatesToStageItemInputsTable({ + stageItemsLookup, + updates, +}: { + stageItemsLookup: any; + updates: Update[]; +}) { + return updates + .sort((si1: Update, si2: Update) => + si1.stage_item_input.slot > si2.stage_item_input.slot ? 1 : -1 + ) + .map((update) => ( + + + {formatStageItemInput(update.stage_item_input, stageItemsLookup)} + + + + {update.team?.name} + + + )); +} + +function UpdatesToStageItemInputsTables({ + stageItemsLookup, + swrRankingsPerStageItemResponse, +}: { + stageItemsLookup: any; + swrRankingsPerStageItemResponse: SWRResponse; +}) { + if (swrRankingsPerStageItemResponse.isLoading) { + return ; + } + + const items = swrRankingsPerStageItemResponse.data.data; + return Object.keys(items) + .map((stageItemId) => ({ + updates: items[stageItemId], + stageItem: stageItemsLookup[stageItemId], + })) + .filter((item: StageItemUpdate) => item.stageItem != null) + .sort((si1: StageItemUpdate, si2: StageItemUpdate) => + si1.stageItem.name > si2.stageItem.name ? 1 : -1 + ) + .map((item: StageItemUpdate) => ( + <> + + {item.stageItem.name} + + + + )); +} + export default function ActivateNextStageModal({ tournamentId, swrStagesResponse, + swrRankingsPerStageItemResponse, }: { tournamentId: number; swrStagesResponse: SWRResponse; + swrRankingsPerStageItemResponse: SWRResponse; }) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); + const stageItemsLookup = getStageItemLookup(swrStagesResponse); const form = useForm({ initialValues: {}, @@ -40,6 +106,13 @@ export default function ActivateNextStageModal({ {t('active_next_stage_modal_description')} + + + + + diff --git a/frontend/src/components/scheduling/scheduling.tsx b/frontend/src/components/scheduling/scheduling.tsx index 5e46bd7d2..c637bde5f 100644 --- a/frontend/src/components/scheduling/scheduling.tsx +++ b/frontend/src/components/scheduling/scheduling.tsx @@ -7,31 +7,7 @@ import { RoundInterface } from '../../interfaces/round'; import { StageWithStageItems } from '../../interfaces/stage'; import { Tournament } from '../../interfaces/tournament'; import UpcomingMatchesTable from '../tables/upcoming_matches'; -import SwissSettings from './settings/ladder_fixed'; - -function SchedulingSystem({ - tournamentData, - draftRound, - swrStagesResponse, - swrUpcomingMatchesResponse, -}: { - tournamentData: Tournament; - draftRound: RoundInterface; - swrStagesResponse: SWRResponse; - swrUpcomingMatchesResponse: SWRResponse; -}) { - return ( - <> - - - - ); -} +import SwissSettings, { getSwissRoundSchedulingProgress } from './settings/ladder_fixed'; export default function Scheduler({ activeStage, @@ -39,6 +15,7 @@ export default function Scheduler({ draftRound, swrStagesResponse, swrUpcomingMatchesResponse, + swrCourtsResponse, schedulerSettings, }: { activeStage: StageWithStageItems; @@ -46,6 +23,7 @@ export default function Scheduler({ tournamentData: Tournament; swrStagesResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse; + swrCourtsResponse: SWRResponse; schedulerSettings: SchedulerSettings; }) { return ( @@ -53,8 +31,12 @@ export default function Scheduler({

Schedule new matches for {draftRound.name} in {activeStage.name}

- - + + } /> + + {progress.scheduledMatchesCount == null ? null : ( + <> + + + {progress.scheduledMatchesCount} / {progress.courtsCount} {t('courts_filled_badge')} + + + + )} ); } diff --git a/frontend/src/components/tables/upcoming_matches.tsx b/frontend/src/components/tables/upcoming_matches.tsx index d16f40e8f..b89aed333 100644 --- a/frontend/src/components/tables/upcoming_matches.tsx +++ b/frontend/src/components/tables/upcoming_matches.tsx @@ -1,4 +1,5 @@ -import { Badge, Button, Table } from '@mantine/core'; +import { Badge, Button, Center, Stack, Table } from '@mantine/core'; +import { GoChecklist } from '@react-icons/all-files/go/GoChecklist'; import { IconCalendarPlus, IconCheck } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import React from 'react'; @@ -6,19 +7,21 @@ import { FaCheck } from 'react-icons/fa6'; import { SWRResponse } from 'swr'; import { MatchCreateBodyInterface, UpcomingMatchInterface } from '../../interfaces/match'; +import { RoundInterface } from '../../interfaces/round'; import { Tournament } from '../../interfaces/tournament'; import { createMatch } from '../../services/match'; +import { updateRound } from '../../services/round'; import { NoContent } from '../no_content/empty_table_info'; import RequestErrorAlert from '../utils/error_alert'; import TableLayout, { ThNotSortable, ThSortable, getTableState, sortTableEntries } from './table'; export default function UpcomingMatchesTable({ - round_id, + draftRound, tournamentData, swrStagesResponse, swrUpcomingMatchesResponse, }: { - round_id: number; + draftRound: RoundInterface; tournamentData: Tournament; swrStagesResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse; @@ -28,7 +31,7 @@ export default function UpcomingMatchesTable({ swrUpcomingMatchesResponse.data != null ? swrUpcomingMatchesResponse.data.data : []; const tableState = getTableState('elo_diff'); - if (round_id == null) { + if (draftRound == null) { return null; } @@ -42,9 +45,9 @@ export default function UpcomingMatchesTable({ upcoming_match.stage_item_input2.id != null ) { const match_to_schedule: MatchCreateBodyInterface = { - team1_id: upcoming_match.stage_item_input1.id, - team2_id: upcoming_match.stage_item_input2.id, - round_id, + stage_item_input1_id: upcoming_match.stage_item_input1.id, + stage_item_input2_id: upcoming_match.stage_item_input2.id, + round_id: draftRound.id, label: '', }; @@ -78,7 +81,7 @@ export default function UpcomingMatchesTable({ color="green" size="xs" style={{ marginRight: 10 }} - onClick={() => scheduleMatch(upcoming_match)} + onClick={async () => scheduleMatch(upcoming_match)} leftSection={} > {t('schedule_title')} @@ -89,11 +92,29 @@ export default function UpcomingMatchesTable({ if (rows.length < 1) { return ( - } - /> + + } + /> +
+ +
+
); } diff --git a/frontend/src/interfaces/match.tsx b/frontend/src/interfaces/match.tsx index 9e868120e..165915d65 100644 --- a/frontend/src/interfaces/match.tsx +++ b/frontend/src/interfaces/match.tsx @@ -1,8 +1,7 @@ -import assert from 'assert'; import { useTranslation } from 'next-i18next'; import { Court } from './court'; -import { StageItemInput, getPositionName } from './stage_item_input'; +import { StageItemInput, formatStageItemInput } from './stage_item_input'; export interface MatchInterface { id: number; @@ -53,8 +52,8 @@ export interface UpcomingMatchInterface { export interface MatchCreateBodyInterface { round_id: number; - team1_id: number; - team2_id: number; + stage_item_input1_id: number; + stage_item_input2_id: number; label: string; } @@ -97,19 +96,14 @@ export function formatMatchInput1( match: MatchInterface ): string { const { t } = useTranslation(); - if (match.stage_item_input1?.team != null) return match.stage_item_input1.team.name; - if (match.stage_item_input1?.winner_from_stage_item_id != null) { - assert(match.stage_item_input1.winner_position != null); - return `${getPositionName(match.stage_item_input1.winner_position)} of ${ - stageItemsLookup[match.stage_item_input1?.winner_from_stage_item_id].name - }`; - } + const formatted = formatStageItemInput(match.stage_item_input1, stageItemsLookup); + if (formatted != null) return formatted; + if (match.stage_item_input1_winner_from_match_id == null) { return t('empty_slot'); } const winner = matchesLookup[match.stage_item_input1_winner_from_match_id].match; const match_1 = formatMatchInput1(stageItemsLookup, matchesLookup, winner); - // eslint-disable-next-line @typescript-eslint/no-use-before-define const match_2 = formatMatchInput2(stageItemsLookup, matchesLookup, winner); return `Winner of match ${match_1} - ${match_2}`; } @@ -120,13 +114,9 @@ export function formatMatchInput2( match: MatchInterface ): string { const { t } = useTranslation(); - if (match.stage_item_input2?.team != null) return match.stage_item_input2.team.name; - if (match.stage_item_input2?.winner_from_stage_item_id != null) { - assert(match.stage_item_input2.winner_position != null); - return `${getPositionName(match.stage_item_input2.winner_position)} of ${ - stageItemsLookup[match.stage_item_input2?.winner_from_stage_item_id].name - }`; - } + const formatted = formatStageItemInput(match.stage_item_input2, stageItemsLookup); + if (formatted != null) return formatted; + if (match.stage_item_input2_winner_from_match_id == null) { return t('empty_slot'); } diff --git a/frontend/src/interfaces/round.tsx b/frontend/src/interfaces/round.tsx index ae261f505..2ffbd1094 100644 --- a/frontend/src/interfaces/round.tsx +++ b/frontend/src/interfaces/round.tsx @@ -6,6 +6,5 @@ export interface RoundInterface { created: string; name: string; is_draft: boolean; - is_active: boolean; matches: MatchInterface[]; } diff --git a/frontend/src/interfaces/stage_item.tsx b/frontend/src/interfaces/stage_item.tsx index c17750bbd..69343aac7 100644 --- a/frontend/src/interfaces/stage_item.tsx +++ b/frontend/src/interfaces/stage_item.tsx @@ -11,8 +11,5 @@ export interface StageItemWithRounds { is_active: boolean; rounds: RoundInterface[]; inputs: StageItemInput[]; -} - -export function stageItemIsHandledAutomatically(activeStage: StageItemWithRounds) { - return ['ROUND_ROBIN', 'SINGLE_ELIMINATION'].includes(activeStage.type); + stage_id: number; } diff --git a/frontend/src/interfaces/stage_item_input.tsx b/frontend/src/interfaces/stage_item_input.tsx index c4ce3691b..aec27cb31 100644 --- a/frontend/src/interfaces/stage_item_input.tsx +++ b/frontend/src/interfaces/stage_item_input.tsx @@ -1,3 +1,5 @@ +import assert from 'assert'; + import { TeamInterface } from './team'; export interface StageItemInput { @@ -58,7 +60,25 @@ export function getPositionName(position: number) { ); } -export function formatStageItemInput(winner_position: number, teamName: string) { - // @ts-ignore - return `${getPositionName(winner_position)} of ${teamName}`; +export function formatStageItemInputTentative( + stage_item_input: StageItemInput | StageItemInputOption, + stageItemsLookup: any +) { + assert( + stage_item_input.winner_from_stage_item_id != null && stage_item_input.winner_position != null + ); + return `${getPositionName(stage_item_input.winner_position)} of ${stageItemsLookup[stage_item_input.winner_from_stage_item_id].name}`; +} + +export function formatStageItemInput( + stage_item_input: StageItemInput | null, + stageItemsLookup: any +) { + if (stage_item_input == null) return null; + if (stage_item_input?.team != null) return stage_item_input.team.name; + if (stage_item_input?.winner_from_stage_item_id != null) { + assert(stage_item_input.winner_position != null); + return formatStageItemInputTentative(stage_item_input, stageItemsLookup); + } + return null; } diff --git a/frontend/src/pages/tournaments/[id]/results.tsx b/frontend/src/pages/tournaments/[id]/results.tsx index 383a58fde..02e3ec69b 100644 --- a/frontend/src/pages/tournaments/[id]/results.tsx +++ b/frontend/src/pages/tournaments/[id]/results.tsx @@ -257,7 +257,7 @@ export default function SchedulePage() { match={match} opened={modalOpened} setOpened={modalSetOpenedAndUpdateMatch} - dynamicSchedule={false} + round={null} /> {t('results_title')}
diff --git a/frontend/src/pages/tournaments/[id]/schedule.tsx b/frontend/src/pages/tournaments/[id]/schedule.tsx index 2a7ad2321..f883c6b4b 100644 --- a/frontend/src/pages/tournaments/[id]/schedule.tsx +++ b/frontend/src/pages/tournaments/[id]/schedule.tsx @@ -281,7 +281,7 @@ export default function SchedulePage() { match={match} opened={modalOpened} setOpened={modalSetOpened} - dynamicSchedule={false} + round={null} /> ) : null} diff --git a/frontend/src/pages/tournaments/[id]/stages/index.tsx b/frontend/src/pages/tournaments/[id]/stages/index.tsx index 5cdf644c9..e664ac68b 100644 --- a/frontend/src/pages/tournaments/[id]/stages/index.tsx +++ b/frontend/src/pages/tournaments/[id]/stages/index.tsx @@ -15,6 +15,7 @@ import { StageWithStageItems } from '../../../../interfaces/stage'; import { getAvailableStageItemInputs, getRankings, + getRankingsPerStageItem, getStages, getTournamentById, } from '../../../../services/adapter'; @@ -27,6 +28,7 @@ export default function StagesPage() { const swrRankingsResponse = getRankings(tournamentData.id); const swrTournamentResponse = getTournamentById(tournamentData.id); const swrAvailableInputsResponse = getAvailableStageItemInputs(tournamentData.id); + const swrRankingsPerStageItemResponse = getRankingsPerStageItem(tournamentData.id); const tournamentDataFull = swrTournamentResponse.data != null ? swrTournamentResponse.data.data : null; const rankings: Ranking[] = swrRankingsResponse.data != null ? swrRankingsResponse.data.data : []; @@ -59,10 +61,12 @@ export default function StagesPage() { @@ -70,6 +74,7 @@ export default function StagesPage() { tournament={tournamentDataFull} swrStagesResponse={swrStagesResponse} swrAvailableInputsResponse={swrAvailableInputsResponse} + swrRankingsPerStageItemResponse={swrRankingsPerStageItemResponse} rankings={rankings} /> diff --git a/frontend/src/pages/tournaments/[id]/stages/swiss/[stage_item_id].tsx b/frontend/src/pages/tournaments/[id]/stages/swiss/[stage_item_id].tsx index ce88915e4..d48add462 100644 --- a/frontend/src/pages/tournaments/[id]/stages/swiss/[stage_item_id].tsx +++ b/frontend/src/pages/tournaments/[id]/stages/swiss/[stage_item_id].tsx @@ -23,7 +23,6 @@ import { BracketDisplaySettings } from '../../../../../interfaces/brackets'; import { SchedulerSettings } from '../../../../../interfaces/match'; import { RoundInterface } from '../../../../../interfaces/round'; import { getStageById } from '../../../../../interfaces/stage'; -import { stageItemIsHandledAutomatically } from '../../../../../interfaces/stage_item'; import { Tournament } from '../../../../../interfaces/tournament'; import { checkForAuthError, @@ -121,25 +120,18 @@ export default function TournamentPage() { } } - const swrUpcomingMatchesResponse = getUpcomingMatches(id, stageItemId, schedulerSettings); - const scheduler = + const swrUpcomingMatchesResponse = getUpcomingMatches( + id, + stageItemId, + draftRound, + schedulerSettings + ); + const showScheduler = draftRound != null && stageItem != null && - !stageItemIsHandledAutomatically(stageItem) && activeStage != null && displaySettings.showManualSchedulingOptions === 'true' && - swrUpcomingMatchesResponse != null ? ( - <> - - - ) : null; + swrUpcomingMatchesResponse != null; if (!swrTournamentResponse.isLoading && tournamentDataFull == null) { return ; @@ -199,15 +191,22 @@ export default function TournamentPage() { - {scheduler} + {showScheduler ? ( + + ) : null}
); diff --git a/frontend/src/services/adapter.tsx b/frontend/src/services/adapter.tsx index 94ed63aa0..3b53337ff 100644 --- a/frontend/src/services/adapter.tsx +++ b/frontend/src/services/adapter.tsx @@ -6,6 +6,7 @@ import useSWR, { SWRResponse } from 'swr'; import { Pagination } from '../components/utils/util'; import { SchedulerSettings } from '../interfaces/match'; +import { RoundInterface } from '../interfaces/round'; import { getLogin, performLogout, tokenPresent } from './local_storage'; // TODO: This is a workaround for the fact that axios is not properly typed. @@ -181,6 +182,10 @@ export function getRankings(tournament_id: number): SWRResponse { return useSWR(`tournaments/${tournament_id}/rankings`, fetcher); } +export function getRankingsPerStageItem(tournament_id: number): SWRResponse { + return useSWR(`tournaments/${tournament_id}/next_stage_rankings`, fetcher); +} + export function getCourts(tournament_id: number): SWRResponse { return useSWR(`tournaments/${tournament_id}/courts`, fetcher); } @@ -198,10 +203,11 @@ export function getUser(): SWRResponse { export function getUpcomingMatches( tournament_id: number, stage_item_id: number, + draftRound: RoundInterface | null, schedulerSettings: SchedulerSettings ): SWRResponse { return useSWR( - stage_item_id == null + stage_item_id == null || draftRound == null ? null : `tournaments/${tournament_id}/stage_items/${stage_item_id}/upcoming_matches?elo_diff_threshold=${schedulerSettings.eloThreshold}&only_recommended=${schedulerSettings.onlyRecommended}&limit=${schedulerSettings.limit}&iterations=${schedulerSettings.iterations}`, fetcher diff --git a/frontend/src/services/round.tsx b/frontend/src/services/round.tsx index 46aa6ead8..71fb5c999 100644 --- a/frontend/src/services/round.tsx +++ b/frontend/src/services/round.tsx @@ -1,4 +1,3 @@ -import { RoundInterface } from '../interfaces/round'; import { createAxios, handleRequestError } from './adapter'; export async function createRound(tournament_id: number, stage_item_id: number) { @@ -9,31 +8,20 @@ export async function createRound(tournament_id: number, stage_item_id: number) .catch((response: any) => handleRequestError(response)); } -export async function createMatchesAuto( - tournament_id: number, - stage_item_id: number, - elo_diff_threshold: number, - only_recommended: string, - iterations: number -) { - return createAxios() - .post(`tournaments/${tournament_id}/stage_items/${stage_item_id}/schedule_auto`, { - elo_diff_threshold, - only_recommended, - iterations, - }) - .catch((response: any) => handleRequestError(response)); -} - export async function deleteRound(tournament_id: number, round_id: number) { return createAxios() .delete(`tournaments/${tournament_id}/rounds/${round_id}`) .catch((response: any) => handleRequestError(response)); } -export async function updateRound(tournament_id: number, round_id: number, round: RoundInterface) { +export async function updateRound( + tournament_id: number, + round_id: number, + name: string, + is_draft: boolean +) { return createAxios() - .put(`tournaments/${tournament_id}/rounds/${round_id}`, round) + .put(`tournaments/${tournament_id}/rounds/${round_id}`, { name, is_draft }) .catch((response: any) => handleRequestError(response)); }