Implement custom time per match (#337)

This commit is contained in:
Erik Vroon
2023-11-21 21:11:25 +01:00
committed by GitHub
parent 4e616d8d97
commit 4b3dfb9b20
39 changed files with 690 additions and 189 deletions

View File

@@ -0,0 +1,33 @@
"""create custom match duration fields
Revision ID: 8bae62f80db7
Revises: d104afae31e9
Create Date: 2023-11-19 15:05:51.284093
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str | None = '8bae62f80db7'
down_revision: str | None = 'd104afae31e9'
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column('matches', sa.Column('margin_minutes', sa.Integer(), nullable=True))
op.add_column('matches', sa.Column('custom_duration_minutes', sa.Integer(), nullable=True))
op.add_column('matches', sa.Column('custom_margin_minutes', sa.Integer(), nullable=True))
op.add_column(
'tournaments', sa.Column('margin_minutes', sa.Integer(), server_default='5', nullable=False)
)
def downgrade() -> None:
op.drop_column('tournaments', 'margin_minutes')
op.drop_column('matches', 'custom_margin_minutes')
op.drop_column('matches', 'custom_duration_minutes')
op.drop_column('matches', 'margin_minutes')

View File

@@ -15,7 +15,10 @@ from bracket.models.db.stage_item_inputs import StageItemInputGeneric
from bracket.models.db.tournament import Tournament
from bracket.models.db.util import StageWithStageItems
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_create_match, sql_reschedule_match
from bracket.sql.matches import (
sql_create_match,
sql_reschedule_match_and_determine_duration_and_margin,
)
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
@@ -45,11 +48,16 @@ async def schedule_all_unscheduled_matches(tournament_id: int) -> None:
for round_ in stage_item.rounds:
for match in round_.matches:
if match.start_time is None and match.position_in_schedule is None:
await sql_reschedule_match(
assert_some(match.id), court.id, start_time, position_in_schedule
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id),
court.id,
start_time,
position_in_schedule,
match,
tournament,
)
start_time += timedelta(minutes=15)
start_time += timedelta(minutes=match.duration_minutes)
position_in_schedule += 1
for stage in stages[1:]:
@@ -58,12 +66,17 @@ async def schedule_all_unscheduled_matches(tournament_id: int) -> None:
for stage_item in stage.stage_items:
for round_ in stage_item.rounds:
for match in round_.matches:
start_time += timedelta(minutes=15)
start_time += timedelta(minutes=match.duration_minutes)
position_in_schedule += 1
if match.start_time is None and match.position_in_schedule is None:
await sql_reschedule_match(
assert_some(match.id), courts[-1].id, start_time, position_in_schedule
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id),
courts[-1].id,
start_time,
position_in_schedule,
match,
tournament,
)
@@ -131,13 +144,14 @@ async def iterative_scheduling(
winner_from_match_id=match.team2_winner_from_match_id,
)
team_defs = {match.team1_id, match.team2_id}
court_id = sorted(match_count_per_court.items(), key=lambda x: x[1])[0][0]
try:
position_in_schedule = len(matches_per_court[court_id])
last_match = matches_per_court[court_id][-1]
start_time = assert_some(last_match.start_time) + timedelta(minutes=15)
start_time = assert_some(last_match.start_time) + timedelta(
minutes=match.duration_minutes
)
except IndexError:
start_time = tournament.start_time
position_in_schedule = 0
@@ -162,8 +176,8 @@ async def iterative_scheduling(
attempts_since_last_write = 0
random.shuffle(matches_to_schedule)
await sql_reschedule_match(
assert_some(match.id), court_id, start_time, position_in_schedule
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id), court_id, start_time, position_in_schedule, match, tournament
)
@@ -184,13 +198,17 @@ async def reorder_matches_for_court(
last_start_time = tournament.start_time
for i, match_pos in enumerate(matches_this_court):
await sql_reschedule_match(
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match_pos.match.id),
court_id,
last_start_time,
position_in_schedule=i,
match=match_pos.match,
tournament=tournament,
)
last_start_time = last_start_time + timedelta(
minutes=match_pos.match.duration_minutes + match_pos.match.margin_minutes
)
last_start_time = last_start_time + timedelta(minutes=15)
async def handle_match_reschedule(

View File

@@ -1,13 +1,20 @@
from heliclockter import timedelta
from heliclockter import datetime_utc, timedelta
from bracket.logic.planning.matches import get_scheduled_matches_per_court
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_reschedule_match
from bracket.sql.matches import (
sql_reschedule_match_and_determine_duration_and_margin,
)
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
class MatchTimingAdjustmentInfeasible(Exception):
pass
def get_active_and_next_rounds(
stage_item: StageItemWithRounds,
) -> tuple[RoundWithMatches | None, RoundWithMatches | None]:
@@ -29,11 +36,13 @@ def get_active_and_next_rounds(
async def schedule_all_matches_for_swiss_round(
tournament_id: int, active_round: RoundWithMatches
tournament_id: int, active_round: RoundWithMatches, adjust_to_time: datetime_utc | None
) -> None:
courts = await get_all_courts_in_tournament(tournament_id)
stages = await get_full_tournament_details(tournament_id)
tournament = await sql_get_tournament(tournament_id)
matches_per_court = get_scheduled_matches_per_court(stages)
rescheduling_operations = []
if len(courts) < 1:
return
@@ -42,11 +51,60 @@ async def schedule_all_matches_for_swiss_round(
for i, match in enumerate(active_round.matches):
court_id = assert_some(courts[i].id)
last_match = matches_per_court[court_id][-1]
await sql_reschedule_match(
assert_some(match.id),
court_id,
assert_some(last_match.match.start_time) + timedelta(minutes=15),
assert_some(last_match.match.position_in_schedule) + 1,
last_match = (
next((m for m in matches_per_court[court_id][::-1] if m.match.id != match.id), None)
if court_id in matches_per_court
else None
)
if last_match is not None:
timing_difference_minutes = 0.0
if adjust_to_time is not None:
last_match_end = last_match.match.end_time
timing_difference_minutes = (adjust_to_time - last_match_end).total_seconds() // 60
if (
timing_difference_minutes < 0
and -timing_difference_minutes > last_match.match.margin_minutes
):
raise MatchTimingAdjustmentInfeasible(
"A match from the previous round is still happening"
)
if timing_difference_minutes != 0:
last_match_adjusted = last_match.match.copy(
update={
'custom_margin_minutes': last_match.match.margin_minutes
+ timing_difference_minutes
}
)
rescheduling_operations.append(
sql_reschedule_match_and_determine_duration_and_margin(
assert_some(last_match.match.id),
court_id,
assert_some(last_match.match.start_time),
assert_some(last_match.match.position_in_schedule),
last_match_adjusted,
tournament,
)
)
start_time = assert_some(last_match.match.start_time) + timedelta(
minutes=match.duration_minutes
+ last_match.match.margin_minutes
+ timing_difference_minutes
)
pos_in_schedule = assert_some(last_match.match.position_in_schedule) + 1
else:
start_time = tournament.start_time
pos_in_schedule = 1
rescheduling_operations.append(
sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id), court_id, start_time, pos_in_schedule, match, tournament
)
)
# TODO: if safe: await asyncio.gather(*rescheduling_operations)
for op in rescheduling_operations:
await op

View File

@@ -1,12 +1,14 @@
from bracket.logic.planning.matches import create_match_and_assign_free_court
from bracket.models.db.match import Match, MatchCreateBody
from bracket.models.db.tournament import Tournament
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds
from bracket.sql.rounds import get_rounds_for_stage_item
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
def determine_matches_first_round(
round_: RoundWithMatches, stage_item: StageItemWithRounds
round_: RoundWithMatches, stage_item: StageItemWithRounds, tournament: Tournament
) -> list[MatchCreateBody]:
suggestions: list[MatchCreateBody] = []
@@ -25,6 +27,10 @@ def determine_matches_first_round(
team2_winner_from_stage_item_id=second_input.winner_from_stage_item_id,
team2_winner_position=second_input.winner_position,
team2_winner_from_match_id=second_input.winner_from_match_id,
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
)
)
@@ -34,6 +40,7 @@ def determine_matches_first_round(
def determine_matches_subsequent_round(
prev_matches: list[Match],
round_: RoundWithMatches,
tournament: Tournament,
) -> list[MatchCreateBody]:
suggestions: list[MatchCreateBody] = []
@@ -53,6 +60,10 @@ def determine_matches_subsequent_round(
team2_winner_position=None,
team1_winner_from_match_id=assert_some(first_match.id),
team2_winner_from_match_id=assert_some(second_match.id),
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
)
)
return suggestions
@@ -62,18 +73,20 @@ async def build_single_elimination_stage_item(
tournament_id: int, stage_item: StageItemWithRounds
) -> None:
rounds = await get_rounds_for_stage_item(tournament_id, stage_item.id)
tournament = await sql_get_tournament(tournament_id)
assert len(rounds) > 0
first_round = rounds[0]
prev_matches = [
await create_match_and_assign_free_court(tournament_id, match)
for match in determine_matches_first_round(first_round, stage_item)
for match in determine_matches_first_round(first_round, stage_item, tournament)
]
for round_ in rounds[1:]:
prev_matches = [
await create_match_and_assign_free_court(tournament_id, match)
for match in determine_matches_subsequent_round(prev_matches, round_)
for match in determine_matches_subsequent_round(prev_matches, round_, tournament)
]

View File

@@ -61,7 +61,9 @@ def get_possible_upcoming_matches_for_swiss(
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
draft_round_team_ids = get_draft_round_team_ids(draft_round)
teams_to_schedule = [team for team in teams if team.id not in draft_round_team_ids]
teams_to_schedule = [
team for team in teams if team.id not in draft_round_team_ids and team.active
]
if len(teams_to_schedule) < 1:
return suggestions

View File

@@ -3,6 +3,7 @@ from bracket.models.db.match import (
MatchCreateBody,
)
from bracket.models.db.util import StageItemWithRounds
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
@@ -36,6 +37,7 @@ def get_round_robin_combinations(team_count: int) -> list[list[tuple[int, int]]]
async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItemWithRounds) -> None:
matches = get_round_robin_combinations(len(stage_item.inputs))
tournament = await sql_get_tournament(tournament_id)
for i, round_ in enumerate(stage_item.rounds):
for team_1_id, team_2_id in matches[i]:
@@ -53,6 +55,10 @@ async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItem
team2_winner_position=team_2.winner_position,
team2_winner_from_match_id=team_2.winner_from_match_id,
court_id=None,
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
)
await create_match_and_assign_free_court(tournament_id, match)

View File

@@ -13,7 +13,10 @@ class MatchBase(BaseModelORM):
id: int | None = None
created: datetime_utc
start_time: datetime_utc | None
duration_minutes: int | None
duration_minutes: int
margin_minutes: int
custom_duration_minutes: int | None
custom_margin_minutes: int | None
position_in_schedule: int | None
round_id: int
team1_score: int
@@ -21,11 +24,10 @@ class MatchBase(BaseModelORM):
court_id: int | None
@property
def end_time(self, default_minutes: int = 15) -> datetime_utc:
def end_time(self) -> datetime_utc:
assert self.start_time
return datetime_utc.from_datetime(
self.start_time
+ timedelta(minutes=self.duration_minutes if self.duration_minutes else default_minutes)
self.start_time + timedelta(minutes=self.duration_minutes + self.margin_minutes)
)
@@ -83,9 +85,11 @@ class MatchBody(BaseModelORM):
team1_score: int = 0
team2_score: int = 0
court_id: int | None
custom_duration_minutes: int | None
custom_margin_minutes: int | None
class MatchCreateBody(BaseModelORM):
class MatchCreateBodyFrontend(BaseModelORM):
round_id: int
court_id: int | None
team1_id: int | None
@@ -98,6 +102,13 @@ class MatchCreateBody(BaseModelORM):
team2_winner_from_match_id: int | None
class MatchCreateBody(MatchCreateBodyFrontend):
duration_minutes: int
margin_minutes: int
custom_duration_minutes: int | None
custom_margin_minutes: int | None
class MatchRescheduleBody(BaseModelORM):
old_court_id: int
old_position: int

View File

@@ -36,6 +36,10 @@ class StageItemUpdateBody(BaseModelORM):
name: str
class StageItemActivateNextBody(BaseModelORM):
adjust_to_time: datetime_utc | None
class StageItemCreateBody(BaseModelORM):
stage_id: int
name: str | None

View File

@@ -1,4 +1,5 @@
from heliclockter import datetime_utc
from pydantic import Field
from bracket.models.db.shared import BaseModelORM
@@ -9,6 +10,8 @@ class Tournament(BaseModelORM):
name: str
created: datetime_utc
start_time: datetime_utc
duration_minutes: int = Field(..., ge=1)
margin_minutes: int = Field(..., ge=0)
dashboard_public: bool
dashboard_endpoint: str | None
logo_path: str | None
@@ -23,6 +26,8 @@ class TournamentUpdateBody(BaseModelORM):
dashboard_endpoint: str | None
players_can_be_in_multiple_teams: bool
auto_assign_courts: bool
duration_minutes: int = Field(..., ge=1)
margin_minutes: int = Field(..., ge=0)
class TournamentBody(TournamentUpdateBody):

View File

@@ -13,6 +13,7 @@ from bracket.models.db.match import (
Match,
MatchBody,
MatchCreateBody,
MatchCreateBodyFrontend,
MatchFilter,
MatchRescheduleBody,
SuggestedMatch,
@@ -24,7 +25,8 @@ from bracket.routes.auth import user_authenticated_for_tournament
from bracket.routes.models import SingleMatchResponse, SuccessResponse, UpcomingMatchesResponse
from bracket.routes.util import match_dependency, round_dependency, round_with_matches_dependency
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_delete_match, sql_update_match
from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
router = APIRouter()
@@ -72,13 +74,18 @@ async def delete_match(
@router.post("/tournaments/{tournament_id}/matches", response_model=SingleMatchResponse)
async def create_match(
tournament_id: int,
match_body: MatchCreateBody,
match_body: MatchCreateBodyFrontend,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SingleMatchResponse:
return SingleMatchResponse(
data=await create_match_and_assign_free_court(tournament_id, match_body)
tournament = await sql_get_tournament(tournament_id)
body_with_durations = MatchCreateBody(
**match_body.dict(),
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
)
return SingleMatchResponse(data=await sql_create_match(body_with_durations))
@router.post("/tournaments/{tournament_id}/schedule_matches", response_model=SuccessResponse)
async def schedule_matches(
@@ -123,7 +130,9 @@ async def create_matches_automatically(
limit=1,
iterations=iterations,
)
courts = await get_all_courts_in_tournament(tournament_id)
tournament = await sql_get_tournament(tournament_id)
limit = len(courts) - len(round_.matches)
for __ in range(limit):
@@ -150,6 +159,10 @@ async def create_matches_automatically(
team2_winner_from_stage_item_id=None,
team2_winner_position=None,
team2_winner_from_match_id=None,
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
),
)
@@ -164,6 +177,8 @@ async def update_match_by_id(
match: Match = Depends(match_dependency),
) -> SuccessResponse:
assert match.id
await sql_update_match(match.id, match_body)
tournament = await sql_get_tournament(tournament_id)
await sql_update_match(match.id, match_body, tournament)
await recalculate_ranking_for_tournament_id(tournament_id)
return SuccessResponse()

View File

@@ -3,6 +3,7 @@ from starlette import status
from bracket.database import database
from bracket.logic.planning.rounds import (
MatchTimingAdjustmentInfeasible,
get_active_and_next_rounds,
schedule_all_matches_for_swiss_round,
)
@@ -10,7 +11,11 @@ from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.scheduling.builder import (
build_matches_for_stage_item,
)
from bracket.models.db.stage_item import StageItemCreateBody, StageItemUpdateBody
from bracket.models.db.stage_item import (
StageItemActivateNextBody,
StageItemCreateBody,
StageItemUpdateBody,
)
from bracket.models.db.user import UserPublic
from bracket.models.db.util import StageItemWithRounds
from bracket.routes.auth import (
@@ -87,6 +92,7 @@ async def update_stage_item(
async def start_next_round(
tournament_id: int,
stage_item_id: int,
active_next_body: StageItemActivateNextBody,
stage_item: StageItemWithRounds = Depends(stage_item_dependency),
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
@@ -100,6 +106,16 @@ async def start_next_round(
),
)
try:
await schedule_all_matches_for_swiss_round(
tournament_id, next_round, active_next_body.adjust_to_time
)
except MatchTimingAdjustmentInfeasible as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
if active_round is not None and active_round.id is not None:
await set_round_active_or_draft(
active_round.id, tournament_id, is_active=False, is_draft=False
@@ -108,5 +124,4 @@ async def start_next_round(
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 schedule_all_matches_for_swiss_round(tournament_id, next_round)
return SuccessResponse()

View File

@@ -28,6 +28,7 @@ tournaments = Table(
Column('players_can_be_in_multiple_teams', Boolean, nullable=False, server_default='f'),
Column('auto_assign_courts', Boolean, nullable=False, server_default='f'),
Column('duration_minutes', Integer, nullable=False, server_default='15'),
Column('margin_minutes', Integer, nullable=False, server_default='5'),
)
stages = Table(
@@ -97,6 +98,9 @@ matches = Table(
Column('created', DateTimeTZ, nullable=False),
Column('start_time', DateTimeTZ, nullable=True),
Column('duration_minutes', Integer, nullable=True),
Column('margin_minutes', Integer, nullable=True),
Column('custom_duration_minutes', Integer, nullable=True),
Column('custom_margin_minutes', Integer, nullable=True),
Column('round_id', BigInteger, ForeignKey('rounds.id'), nullable=False),
Column('team1_id', BigInteger, ForeignKey('teams.id'), nullable=True),
Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=True),

View File

@@ -4,6 +4,7 @@ from heliclockter import datetime_utc
from bracket.database import database
from bracket.models.db.match import Match, MatchBody, MatchCreateBody
from bracket.models.db.tournament import Tournament
async def sql_delete_match(match_id: int) -> None:
@@ -40,6 +41,10 @@ async def sql_create_match(match: MatchCreateBody) -> Match:
team2_winner_position,
team1_winner_from_match_id,
team2_winner_from_match_id,
duration_minutes,
custom_duration_minutes,
margin_minutes,
custom_margin_minutes,
team1_score,
team2_score,
created
@@ -55,6 +60,10 @@ async def sql_create_match(match: MatchCreateBody) -> Match:
:team2_winner_position,
:team1_winner_from_match_id,
:team2_winner_from_match_id,
:duration_minutes,
:custom_duration_minutes,
:margin_minutes,
:custom_margin_minutes,
0,
0,
NOW()
@@ -69,17 +78,40 @@ async def sql_create_match(match: MatchCreateBody) -> Match:
return Match.parse_obj(result._mapping)
async def sql_update_match(match_id: int, match: MatchBody) -> None:
async def sql_update_match(match_id: int, match: MatchBody, tournament: Tournament) -> None:
query = '''
UPDATE matches
SET round_id = :round_id,
team1_score = :team1_score,
team2_score = :team2_score,
court_id = :court_id
court_id = :court_id,
custom_duration_minutes = :custom_duration_minutes,
custom_margin_minutes = :custom_margin_minutes,
duration_minutes = :duration_minutes,
margin_minutes = :margin_minutes
WHERE matches.id = :match_id
RETURNING *
'''
await database.execute(query=query, values={'match_id': match_id, **match.dict()})
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
)
await database.execute(
query=query,
values={
'match_id': match_id,
**match.dict(),
'duration_minutes': duration_minutes,
'margin_minutes': margin_minutes,
},
)
async def sql_update_team_ids_for_match(
@@ -97,13 +129,24 @@ async def sql_update_team_ids_for_match(
async def sql_reschedule_match(
match_id: int, court_id: int | None, start_time: datetime_utc, position_in_schedule: int | None
match_id: int,
court_id: int | None,
start_time: datetime_utc,
position_in_schedule: int | None,
duration_minutes: int,
margin_minutes: int,
custom_duration_minutes: int | None,
custom_margin_minutes: int | None,
) -> None:
query = '''
UPDATE matches
SET court_id = :court_id,
start_time = :start_time,
position_in_schedule = :position_in_schedule
position_in_schedule = :position_in_schedule,
duration_minutes = :duration_minutes,
margin_minutes = :margin_minutes,
custom_duration_minutes = :custom_duration_minutes,
custom_margin_minutes = :custom_margin_minutes
WHERE matches.id = :match_id
'''
await database.execute(
@@ -113,10 +156,44 @@ async def sql_reschedule_match(
'match_id': match_id,
'position_in_schedule': position_in_schedule,
'start_time': datetime.fromisoformat(start_time.isoformat()),
'duration_minutes': duration_minutes,
'margin_minutes': margin_minutes,
'custom_duration_minutes': custom_duration_minutes,
'custom_margin_minutes': custom_margin_minutes,
},
)
async def sql_reschedule_match_and_determine_duration_and_margin(
match_id: int,
court_id: int | None,
start_time: datetime_utc,
position_in_schedule: int | None,
match: Match,
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
)
await sql_reschedule_match(
match_id,
court_id,
start_time,
position_in_schedule,
duration_minutes,
margin_minutes,
match.custom_duration_minutes,
match.custom_margin_minutes,
)
async def sql_get_match(match_id: int) -> Match:
query = '''
SELECT *

View File

@@ -179,7 +179,7 @@ async def sql_create_dev_db() -> None:
player_id_8 = await insert_dummy(DUMMY_PLAYER8, {'tournament_id': tournament_id_1})
player_id_9 = await insert_dummy(
DUMMY_PLAYER8, {'name': 'Player 9', 'tournament_id': tournament_id_1}
DUMMY_PLAYER8, {'name': 'Player 09', 'tournament_id': tournament_id_1}
)
player_id_10 = await insert_dummy(
DUMMY_PLAYER8, {'name': 'Player 10', 'tournament_id': tournament_id_1}

View File

@@ -36,6 +36,8 @@ DUMMY_TOURNAMENT = Tournament(
logo_path=None,
players_can_be_in_multiple_teams=True,
auto_assign_courts=True,
duration_minutes=10,
margin_minutes=5,
)
DUMMY_STAGE1 = Stage(
@@ -113,6 +115,9 @@ DUMMY_MATCH1 = Match(
team2_winner_position=None,
team2_winner_from_match_id=None,
duration_minutes=10,
margin_minutes=5,
custom_duration_minutes=None,
custom_margin_minutes=None,
position_in_schedule=1,
)
@@ -153,56 +158,56 @@ DUMMY_TEAM4 = Team(
DUMMY_PLAYER1 = Player(
name='Player 1',
name='Player 01',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER2 = Player(
name='Player 2',
name='Player 02',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER3 = Player(
name='Player 3',
name='Player 03',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER4 = Player(
name='Player 4',
name='Player 04',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER5 = Player(
name='Player 5',
name='Player 05',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER6 = Player(
name='Player 6',
name='Player 06',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER7 = Player(
name='Player 7',
name='Player 07',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_PLAYER8 = Player(
name='Player 8',
name='Player 08',
active=True,
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,

View File

@@ -111,7 +111,9 @@ async def test_activate_next_stage(
assert isinstance(match1, MatchWithDetailsDefinitive)
assert match1.team2.id == team_inserted_2.id
await sql_update_match(
assert_some(match1.id), MatchBody(**match1.copy(update={'team2_score': 42}).dict())
assert_some(match1.id),
MatchBody(**match1.copy(update={'team2_score': 42}).dict()),
auth_context.tournament,
)
response = await send_tournament_request(

View File

@@ -239,7 +239,7 @@ async def test_upcoming_matches_endpoint(
{
'id': player_inserted_1.id,
'active': True,
'name': 'Player 1',
'name': 'Player 01',
'created': '2022-01-11T04:32:11+00:00',
'tournament_id': auth_context.tournament.id,
'elo_score': 1100,
@@ -251,7 +251,7 @@ async def test_upcoming_matches_endpoint(
{
'id': player_inserted_3.id,
'active': True,
'name': 'Player 3',
'name': 'Player 03',
'created': '2022-01-11T04:32:11+00:00',
'tournament_id': auth_context.tournament.id,
'elo_score': 1200,
@@ -274,7 +274,7 @@ async def test_upcoming_matches_endpoint(
{
'id': player_inserted_2.id,
'active': True,
'name': 'Player 2',
'name': 'Player 02',
'created': '2022-01-11T04:32:11+00:00',
'tournament_id': auth_context.tournament.id,
'elo_score': 1300,
@@ -286,7 +286,7 @@ async def test_upcoming_matches_endpoint(
{
'id': player_inserted_4.id,
'active': True,
'name': 'Player 4',
'name': 'Player 04',
'created': '2022-01-11T04:32:11+00:00',
'tournament_id': auth_context.tournament.id,
'elo_score': 1400,

View File

@@ -29,7 +29,7 @@ async def test_players_endpoint(
'wins': 0,
'draws': 0,
'losses': 0,
'name': 'Player 1',
'name': 'Player 01',
'tournament_id': auth_context.tournament.id,
}
],

View File

@@ -28,6 +28,8 @@ async def test_tournaments_endpoint(
'dashboard_endpoint': 'cool-tournament',
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
'duration_minutes': 10,
'margin_minutes': 5,
}
],
}
@@ -49,6 +51,8 @@ async def test_tournament_endpoint(
'dashboard_endpoint': 'cool-tournament',
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
'duration_minutes': 10,
'margin_minutes': 5,
},
}
@@ -63,6 +67,8 @@ async def test_create_tournament(
'dashboard_public': False,
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
'duration_minutes': 12,
'margin_minutes': 3,
}
assert (
await send_auth_request(HTTPMethod.POST, 'tournaments', auth_context, json=body)
@@ -80,6 +86,8 @@ async def test_update_tournament(
'dashboard_public': False,
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
'duration_minutes': 12,
'margin_minutes': 3,
}
assert (
await send_tournament_request(HTTPMethod.PUT, '', auth_context, json=body)

View File

@@ -40,6 +40,9 @@ def test_elo_calculation() -> None:
court_id=None,
court=None,
duration_minutes=10,
margin_minutes=5,
custom_duration_minutes=None,
custom_margin_minutes=None,
position_in_schedule=0,
team1=FullTeamWithPlayers(
id=3,

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Container, Grid, Group, Skeleton } from '@mantine/core';
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
import { IconAlertCircle, IconSquareArrowRight } from '@tabler/icons-react';
import { IconAlertCircle } from '@tabler/icons-react';
import React from 'react';
import { SWRResponse } from 'swr';
@@ -9,7 +9,8 @@ import { RoundInterface } from '../../interfaces/round';
import { StageWithStageItems } from '../../interfaces/stage';
import { StageItemWithRounds, stageItemIsHandledAutomatically } from '../../interfaces/stage_item';
import { TournamentMinimal } from '../../interfaces/tournament';
import { createRound, startNextRound } from '../../services/round';
import { createRound } from '../../services/round';
import ActivateNextRoundModal from '../modals/activate_next_round_modal';
import { responseIsValid } from '../utils/util';
import Round from './round';
@@ -17,7 +18,6 @@ function getRoundsGridCols(
stageItem: StageItemWithRounds,
tournamentData: TournamentMinimal,
swrStagesResponse: SWRResponse,
swrCourtsResponse: SWRResponse,
swrUpcomingMatchesResponse: SWRResponse | null,
readOnly: boolean,
displaySettings: BracketDisplaySettings
@@ -30,7 +30,6 @@ function getRoundsGridCols(
tournamentData={tournamentData}
round={round}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
readOnly={readOnly}
dynamicSchedule={!stageItemIsHandledAutomatically(stageItem)}
@@ -46,8 +45,8 @@ function getRoundsGridCols(
);
}
const showAddRoundButton =
tournamentData != null && (readOnly || stageItemIsHandledAutomatically(stageItem));
const hideAddRoundButton =
tournamentData == null || readOnly || stageItemIsHandledAutomatically(stageItem);
return (
<React.Fragment key={stageItem.id}>
@@ -58,7 +57,7 @@ function getRoundsGridCols(
</Grid.Col>
<Grid.Col span={6}>
<Group position="right">
{showAddRoundButton ? null : (
{hideAddRoundButton ? null : (
<Button
color="green"
size="md"
@@ -72,18 +71,12 @@ function getRoundsGridCols(
Add Round
</Button>
)}
{showAddRoundButton ? null : (
<Button
color="indigo"
size="md"
leftIcon={<IconSquareArrowRight size={24} />}
onClick={async () => {
await startNextRound(tournamentData.id, stageItem.id);
await swrStagesResponse.mutate();
}}
>
Start next round
</Button>
{hideAddRoundButton ? null : (
<ActivateNextRoundModal
tournamentId={tournamentData.id}
swrStagesResponse={swrStagesResponse}
stageItem={stageItem}
/>
)}
</Group>
</Grid.Col>
@@ -130,21 +123,20 @@ function NotStartedAlert() {
function LoadingSkeleton() {
return (
<Grid>
<Grid.Col sm={6} lg={4} xl={3}>
<Group>
<div style={{ width: '400px', marginLeft: '1rem' }}>
<Skeleton height={500} mb="xl" radius="xl" />
</Grid.Col>
<Grid.Col sm={6} lg={4} xl={3}>
</div>
<div style={{ width: '400px', marginLeft: '1rem' }}>
<Skeleton height={500} mb="xl" radius="xl" />
</Grid.Col>
</Grid>
</div>
</Group>
);
}
export default function Brackets({
tournamentData,
swrStagesResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly,
selectedStageId,
@@ -152,12 +144,14 @@ export default function Brackets({
}: {
tournamentData: TournamentMinimal;
swrStagesResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
readOnly: boolean;
selectedStageId: string | null;
displaySettings: BracketDisplaySettings;
}) {
if (swrStagesResponse.isLoading) {
return <LoadingSkeleton />;
}
if (selectedStageId == null) {
return <NotStartedAlert />;
}
@@ -182,7 +176,6 @@ export default function Brackets({
stageItem,
tournamentData,
swrStagesResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly,
displaySettings

View File

@@ -20,7 +20,6 @@ function getRoundsGridCols(
key={match.id}
tournamentData={tournamentData}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={null}
swrUpcomingMatchesResponse={null}
match={match}
readOnly

View File

@@ -68,7 +68,6 @@ export function MatchBadge({ match, theme }: { match: MatchInterface; theme: any
export default function Match({
swrStagesResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
tournamentData,
match,
@@ -77,7 +76,6 @@ export default function Match({
displaySettings,
}: {
swrStagesResponse: SWRResponse;
swrCourtsResponse: SWRResponse | null;
swrUpcomingMatchesResponse: SWRResponse | null;
tournamentData: TournamentMinimal;
match: MatchInterface;
@@ -141,7 +139,6 @@ export default function Match({
return <div className={classes.root}>{bracket}</div>;
}
assert(swrStagesResponse != null);
assert(swrCourtsResponse != null);
return (
<>

View File

@@ -17,7 +17,6 @@ export default function Round({
tournamentData,
round,
swrStagesResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly,
dynamicSchedule,
@@ -26,7 +25,6 @@ export default function Round({
tournamentData: TournamentMinimal;
round: RoundInterface;
swrStagesResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
readOnly: boolean;
dynamicSchedule: boolean;
@@ -45,7 +43,6 @@ export default function Round({
key={match.id}
tournamentData={tournamentData}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
match={match}
readOnly={readOnly}

View File

@@ -7,6 +7,9 @@ import { getBaseApiUrl } from '../../services/adapter';
import { getBaseURL } from '../utils/util';
export function TournamentQRCode({ tournamentDataFull }: { tournamentDataFull: Tournament }) {
if (tournamentDataFull == null) {
return null;
}
return (
<div
style={{

View File

@@ -6,7 +6,7 @@ export function MultiPlayersInput({ form }: { form: UseFormReturnType<any> }) {
return (
<Textarea
label="Add multiple players. Put every player on a separate line"
placeholder="Player 1"
placeholder="Player 01"
minRows={10}
{...form.getInputProps('names')}
/>

View File

@@ -0,0 +1,69 @@
import { Button, Checkbox, Modal } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconSquareArrowRight } from '@tabler/icons-react';
import React, { useState } from 'react';
import { SWRResponse } from 'swr';
import { StageItemWithRounds } from '../../interfaces/stage_item';
import { startNextRound } from '../../services/round';
export default function ActivateNextRoundModal({
tournamentId,
stageItem,
swrStagesResponse,
}: {
tournamentId: number;
stageItem: StageItemWithRounds;
swrStagesResponse: SWRResponse;
}) {
const [opened, setOpened] = useState(false);
const form = useForm({
initialValues: {
adjust_to_time: false,
},
});
return (
<>
<Modal opened={opened} onClose={() => setOpened(false)} title="Activate next round">
<form
onSubmit={form.onSubmit(async (values) => {
await startNextRound(
tournamentId,
stageItem.id,
values.adjust_to_time ? new Date() : null
);
await swrStagesResponse.mutate();
setOpened(false);
})}
>
<Checkbox
mt="lg"
label="Adjust start time of matches in this round to the current time"
{...form.getInputProps('adjust_to_time', { type: 'checkbox' })}
/>
<Button
fullWidth
color="indigo"
size="md"
mt="lg"
type="submit"
leftIcon={<IconSquareArrowRight size={24} />}
>
Start next round
</Button>
</form>
</Modal>
<Button
color="indigo"
size="md"
leftIcon={<IconSquareArrowRight size={24} />}
onClick={() => setOpened(true)}
>
Activate next round
</Button>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { Button, Modal, NumberInput } from '@mantine/core';
import { Button, Center, Checkbox, Divider, Grid, Modal, NumberInput, Text } from '@mantine/core';
import { useForm } from '@mantine/form';
import React from 'react';
import React, { useState } from 'react';
import { SWRResponse } from 'swr';
import {
@@ -62,16 +62,29 @@ export default function MatchModal({
}) {
const form = useForm({
initialValues: {
team1_score: match != null ? match.team1_score : 0,
team2_score: match != null ? match.team2_score : 0,
team1_score: match.team1_score,
team2_score: match.team2_score,
custom_duration_minutes: match.custom_duration_minutes,
custom_margin_minutes: match.custom_margin_minutes,
},
validate: {
team1_score: (value) => (value >= 0 ? null : 'Score cannot be negative'),
team2_score: (value) => (value >= 0 ? null : 'Score cannot be negative'),
custom_duration_minutes: (value) =>
value == null || value >= 0 ? null : 'Match duration cannot be negative',
custom_margin_minutes: (value) =>
value == null || value >= 0 ? null : 'Match margin cannot be negative',
},
});
const [customDurationEnabled, setCustomDurationEnabled] = useState(
match.custom_duration_minutes != null
);
const [customMarginEnabled, setCustomMarginEnabled] = useState(
match.custom_margin_minutes != null
);
const stageItemsLookup = getStageItemLookup(swrStagesResponse);
const matchesLookup = getMatchLookup(swrStagesResponse);
@@ -89,6 +102,10 @@ export default function MatchModal({
team1_score: values.team1_score,
team2_score: values.team2_score,
court_id: match.court_id,
custom_duration_minutes: customDurationEnabled
? values.custom_duration_minutes
: null,
custom_margin_minutes: customMarginEnabled ? values.custom_margin_minutes : null,
};
await updateMatch(tournamentData.id, match.id, updatedMatch);
await swrStagesResponse.mutate(null);
@@ -104,11 +121,64 @@ export default function MatchModal({
/>
<NumberInput
withAsterisk
style={{ marginTop: 20 }}
mt="lg"
label={`Score of ${team2Name}`}
placeholder={`Score of ${team2Name}`}
{...form.getInputProps('team2_score')}
/>
<Divider mt="lg" />
<Text size="sm" mt="lg">
Custom match duration
</Text>
<Grid align="center">
<Grid.Col sm={8}>
<NumberInput
disabled={!customDurationEnabled}
rightSection={<Text>minutes</Text>}
placeholder={`${match.duration_minutes}`}
rightSectionWidth={92}
{...form.getInputProps('custom_duration_minutes')}
/>
</Grid.Col>
<Grid.Col sm={4}>
<Center>
<Checkbox
checked={customDurationEnabled}
label="Customize"
onChange={(event) => {
setCustomDurationEnabled(event.currentTarget.checked);
}}
/>
</Center>
</Grid.Col>
</Grid>
<Text size="sm" mt="lg">
Custom match margin
</Text>
<Grid align="center">
<Grid.Col sm={8}>
<NumberInput
disabled={!customMarginEnabled}
placeholder={`${match.margin_minutes}`}
rightSection={<Text>minutes</Text>}
rightSectionWidth={92}
{...form.getInputProps('custom_margin_minutes')}
/>
</Grid.Col>
<Grid.Col sm={4}>
<Center>
<Checkbox
checked={customMarginEnabled}
label="Customize"
onChange={(event) => {
setCustomMarginEnabled(event.currentTarget.checked);
}}
/>
</Center>
</Grid.Col>
</Grid>
<Button fullWidth style={{ marginTop: 20 }} color="green" type="submit">
Save

View File

@@ -0,0 +1,33 @@
import { Center, Grid, Skeleton } from '@mantine/core';
import React from 'react';
export function GenericSkeleton() {
return (
<>
<Skeleton height={75} radius="lg" mb="xl" />
<Skeleton height={75} radius="lg" mb="xl" />
<Skeleton height={75} radius="lg" mb="xl" />
</>
);
}
export function TableSkeletonTwoColumns() {
return (
<Center>
<div style={{ minWidth: '1500px', marginTop: '2rem' }}>
<Grid>
<Grid.Col sm={6}>
<Skeleton height={75} radius="lg" mb="xl" />
<Skeleton height={75} radius="lg" mb="xl" />
<Skeleton height={75} radius="lg" mb="xl" />
</Grid.Col>
<Grid.Col sm={6}>
<Skeleton height={75} radius="lg" mb="xl" />
<Skeleton height={75} radius="lg" mb="xl" />
<Skeleton height={75} radius="lg" mb="xl" />
</Grid.Col>
</Grid>
</div>
</Center>
);
}

View File

@@ -22,7 +22,10 @@ export interface MatchInterface {
court: Court | null;
start_time: string;
position_in_schedule: number | null;
duration_minutes: number | null;
duration_minutes: number;
margin_minutes: number;
custom_duration_minutes: number | null;
custom_margin_minutes: number | null;
}
export interface MatchBodyInterface {
@@ -31,6 +34,8 @@ export interface MatchBodyInterface {
team1_score: number;
team2_score: number;
court_id: number | null;
custom_duration_minutes: number | null;
custom_margin_minutes: number | null;
}
export interface MatchRescheduleInterface {
@@ -66,15 +71,26 @@ export interface SchedulerSettings {
setOnlyRecommended: any;
}
export function isMatchHappening(match: MatchInterface) {
return (
new Date(match.start_time) < new Date() &&
new Date(new Date(match.start_time).getTime() + 60000 * 15) > new Date()
export function getMatchStartTime(match: MatchInterface) {
return new Date(match.start_time);
}
export function getMatchEndTime(match: MatchInterface) {
return new Date(
getMatchStartTime(match).getTime() + 60000 * (match.duration_minutes + match.margin_minutes)
);
}
export function isMatchHappening(match: MatchInterface) {
return getMatchStartTime(match) < new Date() && getMatchEndTime(match) > new Date();
}
export function isMatchInTheFutureOrPresent(match: MatchInterface) {
return new Date(new Date(match.start_time).getTime() + 60000 * 15) > new Date();
return getMatchEndTime(match) > new Date();
}
export function isMatchInTheFuture(match: MatchInterface) {
return getMatchStartTime(match) > new Date();
}
export function formatMatchTeam1(

View File

@@ -9,6 +9,8 @@ export interface Tournament {
players_can_be_in_multiple_teams: boolean;
auto_assign_courts: boolean;
logo_path: string;
duration_minutes: number;
margin_minutes: number;
}
export interface TournamentMinimal {
id: number;

View File

@@ -16,7 +16,6 @@ import { StageItemWithRounds } from '../../interfaces/stage_item';
import { Tournament, getTournamentEndpoint } from '../../interfaces/tournament';
import {
checkForAuthError,
getCourts,
getStages,
getTournaments,
getUpcomingMatches,
@@ -29,7 +28,6 @@ export default function TournamentPage() {
const swrTournamentsResponse = getTournaments();
checkForAuthError(swrTournamentsResponse);
const swrStagesResponse: SWRResponse = getStages(id);
const swrCourtsResponse: SWRResponse = getCourts(id);
const [onlyRecommended, setOnlyRecommended] = useState('true');
const [eloThreshold, setEloThreshold] = useState(100);
const [iterations, setIterations] = useState(200);
@@ -90,10 +88,6 @@ export default function TournamentPage() {
schedulerSettings
);
if (tournamentDataFull == null) {
return <NotFoundTitle />;
}
const scheduler =
draftRound != null &&
activeStage != null &&
@@ -112,11 +106,15 @@ export default function TournamentPage() {
</>
) : null;
if (!swrTournamentsResponse.isLoading && tournamentDataFull == null) {
return <NotFoundTitle />;
}
return (
<TournamentLayout tournament_id={tournamentData.id}>
<Grid grow>
<Grid.Col span={6}>
<Title>{tournamentDataFull.name}</Title>
<Title>{tournamentDataFull != null ? tournamentDataFull.name : ''}</Title>
</Grid.Col>
<Grid.Col span={6}>
<Group position="right">
@@ -163,7 +161,6 @@ export default function TournamentPage() {
<Brackets
tournamentData={tournamentDataFull}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
readOnly={false}
selectedStageId={selectedStageId}

View File

@@ -11,13 +11,10 @@ import {
TournamentQRCode,
TournamentTitle,
} from '../../../../components/dashboard/layout';
import { TableSkeletonTwoColumns } from '../../../../components/utils/skeletons';
import { responseIsValid } from '../../../../components/utils/util';
import { Court } from '../../../../interfaces/court';
import {
MatchInterface,
isMatchHappening,
isMatchInTheFutureOrPresent,
} from '../../../../interfaces/match';
import { MatchInterface, isMatchHappening, isMatchInTheFuture } from '../../../../interfaces/match';
import { getCourtsLive, getStagesLive } from '../../../../services/adapter';
import { getMatchLookupByCourt } from '../../../../services/lookups';
import { getTournamentResponseByEndpointName } from '../../../../services/tournament';
@@ -32,27 +29,24 @@ export default function CourtsPage() {
const swrStagesResponse: SWRResponse = getStagesLive(tournamentId, true);
const swrCourtsResponse: SWRResponse = getCourtsLive(tournamentId);
if (swrStagesResponse.isLoading || swrCourtsResponse.isLoading) {
return <TableSkeletonTwoColumns />;
}
if (notFound) {
return <NotFoundTitle />;
}
const tournamentDataFull = tournamentResponse[0];
const stages = responseIsValid(swrStagesResponse) ? swrStagesResponse.data.data : [];
const tournamentDataFull = tournamentResponse != null ? tournamentResponse[0] : null;
const courts = responseIsValid(swrCourtsResponse) ? swrCourtsResponse.data.data : [];
const matchesByCourtId = responseIsValid(swrStagesResponse)
? getMatchLookupByCourt(swrStagesResponse)
: [];
if (courts.length < 1 || stages.length < 1) {
return <NotFoundTitle />;
}
const rows = courts.map((court: Court) => {
const matchesForCourt = matchesByCourtId[court.id] || [];
const activeMatch = matchesForCourt.filter((m: MatchInterface) => isMatchHappening(m))[0];
const futureMatch = matchesForCourt.filter((m: MatchInterface) =>
isMatchInTheFutureOrPresent(m)
)[0];
const futureMatch = matchesForCourt.filter((m: MatchInterface) => isMatchInTheFuture(m))[0];
return (
<CourtsLarge key={court.id} court={court} activeMatch={activeMatch} nextMatch={futureMatch} />

View File

@@ -15,7 +15,7 @@ import StagesTab from '../../../../components/utils/stages_tab';
import { responseIsValid } from '../../../../components/utils/util';
import { BracketDisplaySettings } from '../../../../interfaces/brackets';
import { StageWithStageItems } from '../../../../interfaces/stage';
import { getCourts, getStagesLive } from '../../../../services/adapter';
import { getStagesLive } from '../../../../services/adapter';
import { getTournamentResponseByEndpointName } from '../../../../services/tournament';
export default function Index() {
@@ -26,7 +26,6 @@ export default function Index() {
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
const swrStagesResponse: SWRResponse = getStagesLive(tournamentId, true);
const swrCourtsResponse: SWRResponse = getCourts(tournamentId);
const [selectedStageId, setSelectedStageId] = useState(null);
const [matchVisibility, setMatchVisibility] = useState('all');
const [teamNamesDisplay, setTeamNamesDisplay] = useState('team-names');
@@ -36,12 +35,11 @@ export default function Index() {
teamNamesDisplay,
setTeamNamesDisplay,
};
if (notFound) {
if (notFound && !swrStagesResponse.isLoading) {
return <NotFoundTitle />;
}
const tournamentDataFull = tournamentResponse[0];
const tournamentDataFull = tournamentResponse != null ? tournamentResponse[0] : null;
if (responseIsValid(swrStagesResponse)) {
const activeTab = swrStagesResponse.data.data.filter(
@@ -75,7 +73,6 @@ export default function Index() {
<Brackets
tournamentData={tournamentDataFull}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={null}
readOnly
selectedStageId={selectedStageId}

View File

@@ -11,6 +11,7 @@ import {
TournamentTitle,
} from '../../../../components/dashboard/layout';
import StandingsTable from '../../../../components/tables/standings';
import { TableSkeletonTwoColumns } from '../../../../components/utils/skeletons';
import { getTeamsLive } from '../../../../services/adapter';
import { getTournamentResponseByEndpointName } from '../../../../services/tournament';
@@ -23,6 +24,10 @@ export default function Standings() {
const swrTeamsResponse: SWRResponse = getTeamsLive(tournamentId);
if (swrTeamsResponse.isLoading) {
return <TableSkeletonTwoColumns />;
}
if (notFound) {
return <NotFoundTitle />;
}

View File

@@ -6,6 +6,7 @@ import {
CopyButton,
Grid,
Image,
NumberInput,
Select,
Text,
TextInput,
@@ -17,12 +18,14 @@ import assert from 'assert';
import React from 'react';
import { SWRResponse } from 'swr';
import NotFoundTitle from '../../404';
import { DropzoneButton } from '../../../components/utils/file_upload';
import { GenericSkeleton } from '../../../components/utils/skeletons';
import { getBaseURL, getTournamentIdFromRouter } from '../../../components/utils/util';
import { Club } from '../../../interfaces/club';
import { Tournament, getTournamentEndpoint } from '../../../interfaces/tournament';
import { getBaseApiUrl, getClubs, getTournaments } from '../../../services/adapter';
import { createTournament, updateTournament } from '../../../services/tournament';
import { updateTournament } from '../../../services/tournament';
import TournamentLayout from '../_tournament_layout';
export function TournamentLogo({ tournament }: { tournament: Tournament | null }) {
@@ -31,26 +34,25 @@ export function TournamentLogo({ tournament }: { tournament: Tournament | null }
}
function GeneralTournamentForm({
is_create_form,
tournament,
swrTournamentsResponse,
clubs,
}: {
is_create_form: boolean;
tournament: Tournament | null;
tournament: Tournament;
swrTournamentsResponse: SWRResponse;
clubs: Club[];
}) {
const form = useForm({
initialValues: {
start_time: tournament == null ? new Date() : new Date(tournament.start_time),
name: tournament == null ? '' : tournament.name,
club_id: tournament == null ? null : `${tournament.club_id}`,
dashboard_public: tournament == null ? true : tournament.dashboard_public,
dashboard_endpoint: tournament == null ? '' : tournament.dashboard_endpoint,
players_can_be_in_multiple_teams:
tournament == null ? true : tournament.players_can_be_in_multiple_teams,
auto_assign_courts: tournament == null ? true : tournament.auto_assign_courts,
start_time: new Date(tournament.start_time),
name: tournament.name,
club_id: `${tournament.club_id}`,
dashboard_public: tournament.dashboard_public,
dashboard_endpoint: tournament.dashboard_endpoint,
players_can_be_in_multiple_teams: tournament.players_can_be_in_multiple_teams,
auto_assign_courts: tournament.auto_assign_courts,
duration_minutes: tournament.duration_minutes,
margin_minutes: tournament.margin_minutes,
},
validate: {
@@ -65,28 +67,19 @@ function GeneralTournamentForm({
<form
onSubmit={form.onSubmit(async (values) => {
assert(values.club_id != null);
if (is_create_form) {
await createTournament(
parseInt(values.club_id, 10),
values.name,
values.dashboard_public,
values.dashboard_endpoint,
values.players_can_be_in_multiple_teams,
values.auto_assign_courts,
values.start_time.toISOString()
);
} else {
assert(tournament != null);
await updateTournament(
tournament.id,
values.name,
values.dashboard_public,
values.dashboard_endpoint,
values.players_can_be_in_multiple_teams,
values.auto_assign_courts,
values.start_time.toISOString()
);
}
await updateTournament(
tournament.id,
values.name,
values.dashboard_public,
values.dashboard_endpoint,
values.players_can_be_in_multiple_teams,
values.auto_assign_courts,
values.start_time.toISOString(),
values.duration_minutes,
values.margin_minutes
);
await swrTournamentsResponse.mutate(null);
})}
>
@@ -120,7 +113,6 @@ function GeneralTournamentForm({
<Grid>
<Grid.Col sm={9}>
<DateTimePicker
label=""
icon={<IconCalendar size="1.1rem" stroke={1.5} />}
placeholder="Pick date and time"
mx="auto"
@@ -142,6 +134,25 @@ function GeneralTournamentForm({
</Grid.Col>
</Grid>
<Grid>
<Grid.Col sm={6}>
<NumberInput
label="Match duration (minutes)"
mt="lg"
type="number"
{...form.getInputProps('duration_minutes')}
/>
</Grid.Col>
<Grid.Col sm={6}>
<NumberInput
label="Time between matches (minutes)"
mt="lg"
type="number"
{...form.getInputProps('margin_minutes')}
/>
</Grid.Col>
</Grid>
<Checkbox
mt="lg"
label="Allow anyone to see the dashboard of rounds and matches"
@@ -185,28 +196,36 @@ function GeneralTournamentForm({
export default function SettingsPage() {
const { tournamentData } = getTournamentIdFromRouter();
const swrClubsResponse: SWRResponse = getClubs();
const swrTournamentsResponse = getTournaments();
const tournaments: Tournament[] =
swrTournamentsResponse.data != null ? swrTournamentsResponse.data.data : [];
const tournamentDataFull = tournaments.filter(
(tournament) => tournament.id === tournamentData.id
)[0];
const is_create_form = tournamentDataFull == null;
const swrClubsResponse: SWRResponse = getClubs();
const clubs: Club[] = swrClubsResponse.data != null ? swrClubsResponse.data.data : [];
let content = <NotFoundTitle />;
if (swrTournamentsResponse.isLoading || swrClubsResponse.isLoading) {
content = <GenericSkeleton />;
}
if (tournamentDataFull != null) {
content = (
<GeneralTournamentForm
tournament={tournamentDataFull}
swrTournamentsResponse={swrTournamentsResponse}
clubs={clubs}
/>
);
}
return (
<TournamentLayout tournament_id={tournamentData.id}>
<Container>
<GeneralTournamentForm
is_create_form={is_create_form}
tournament={tournamentDataFull}
swrTournamentsResponse={swrTournamentsResponse}
clubs={clubs}
/>
</Container>
<Container>{content}</Container>
</TournamentLayout>
);
}

View File

@@ -8,6 +8,14 @@ import { getLogin, performLogout, tokenPresent } from './local_storage';
const axios = require('axios').default;
export function handleRequestError(response: any) {
if (response.code === 'ERR_NETWORK') {
showNotification({
color: 'red',
title: 'An error occurred',
message: 'Internal server error',
});
}
if (response.response != null && response.response.data.detail != null) {
// If the detail contains an array, there is likely a pydantic validation error occurring.
const message = Array.isArray(response.response.data.detail)
@@ -67,7 +75,7 @@ export function getTeams(tournament_id: number): SWRResponse {
export function getTeamsLive(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}/teams`, fetcher, {
refreshInterval: 5000,
refreshInterval: 5_000,
});
}
@@ -76,16 +84,27 @@ export function getAvailableStageItemInputs(tournament_id: number, stage_id: num
}
export function getStages(tournament_id: number, no_draft_rounds: boolean = false): SWRResponse {
return useSWR(`tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`, fetcher);
return useSWR(
tournament_id === -1
? null
: `tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`,
fetcher
);
}
export function getStagesLive(
tournament_id: number,
no_draft_rounds: boolean = false
): SWRResponse {
return useSWR(`tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`, fetcher, {
refreshInterval: 5_000,
});
return useSWR(
tournament_id === -1
? null
: `tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`,
fetcher,
{
refreshInterval: 5_000,
}
);
}
export function getCourts(tournament_id: number): SWRResponse {
@@ -108,7 +127,9 @@ export function getUpcomingMatches(
schedulerSettings: SchedulerSettings
): SWRResponse {
return useSWR(
`tournaments/${tournament_id}/rounds/${round_id}/upcoming_matches?elo_diff_threshold=${schedulerSettings.eloThreshold}&only_recommended=${schedulerSettings.onlyRecommended}&limit=${schedulerSettings.limit}&iterations=${schedulerSettings.iterations}`,
round_id === -1
? null
: `tournaments/${tournament_id}/rounds/${round_id}/upcoming_matches?elo_diff_threshold=${schedulerSettings.eloThreshold}&only_recommended=${schedulerSettings.onlyRecommended}&limit=${schedulerSettings.limit}&iterations=${schedulerSettings.iterations}`,
fetcher
);
}

View File

@@ -27,8 +27,14 @@ export async function updateRound(tournament_id: number, round_id: number, round
.catch((response: any) => handleRequestError(response));
}
export async function startNextRound(tournament_id: number, stage_item_id: number) {
export async function startNextRound(
tournament_id: number,
stage_item_id: number,
adjust_to_time: Date | null
) {
return createAxios()
.post(`tournaments/${tournament_id}/stage_items/${stage_item_id}/start_next_round`)
.post(`tournaments/${tournament_id}/stage_items/${stage_item_id}/start_next_round`, {
adjust_to_time: adjust_to_time != null ? adjust_to_time?.toISOString() : null,
})
.catch((response: any) => handleRequestError(response));
}

View File

@@ -36,7 +36,9 @@ export async function updateTournament(
dashboard_endpoint: string,
players_can_be_in_multiple_teams: boolean,
auto_assign_courts: boolean,
start_time: string
start_time: string,
duration_minutes: number,
margin_minutes: number
) {
return createAxios()
.put(`tournaments/${tournament_id}`, {
@@ -46,6 +48,8 @@ export async function updateTournament(
players_can_be_in_multiple_teams,
auto_assign_courts,
start_time,
duration_minutes,
margin_minutes,
})
.catch((response: any) => handleRequestError(response));
}