diff --git a/python/sports-league-scheduling/README.adoc b/python/sports-league-scheduling/README.adoc new file mode 100644 index 0000000000..c9fa4a7ede --- /dev/null +++ b/python/sports-league-scheduling/README.adoc @@ -0,0 +1,79 @@ += Sports League Scheduling (Python) + +Assign rounds to matches to produce a better schedule for league matches. + +image::./sports-league-scheduling-screenshot.png[] + +* <<prerequisites,Prerequisites>> +* <<run,Run the application>> +* <<test,Test the application>> + +[[prerequisites]] +== Prerequisites + +. Install https://www.python.org/downloads/[Python 3.11+] + +. Install JDK 17+, for example with https://sdkman.io[Sdkman]: ++ +---- +$ sdk install java +---- + +[[run]] +== Run the application + +. Git clone the timefold-quickstarts repo and navigate to this directory: ++ +[source, shell] +---- +$ git clone https://github.com/TimefoldAI/timefold-quickstarts.git +... +$ cd timefold-quickstarts/python/sports-league-scheduling +---- + +. Create a virtual environment ++ +[source, shell] +---- +$ python -m venv .venv +---- + +. Activate the virtual environment ++ +[source, shell] +---- +$ . .venv/bin/activate +---- + +. Install the application ++ +[source, shell] +---- +$ pip install -e . +---- + +. Run the application ++ +[source, shell] +---- +$ run-app +---- + +. Visit http://localhost:8080 in your browser. + +. Click on the *Solve* button. + + +[[test]] +== Test the application + +. Run tests ++ +[source, shell] +---- +$ pytest +---- + +== More information + +Visit https://timefold.ai[timefold.ai]. diff --git a/python/sports-league-scheduling/logging.conf b/python/sports-league-scheduling/logging.conf new file mode 100644 index 0000000000..b9dd947471 --- /dev/null +++ b/python/sports-league-scheduling/logging.conf @@ -0,0 +1,30 @@ +[loggers] +keys=root,timefold_solver + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=INFO +handlers=consoleHandler + +[logger_timefold_solver] +level=INFO +qualname=timefold.solver +handlers=consoleHandler +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=INFO +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +class=uvicorn.logging.ColourizedFormatter +format={levelprefix:<8} @ {name} : {message} +style={ +use_colors=True diff --git a/python/sports-league-scheduling/pyproject.toml b/python/sports-league-scheduling/pyproject.toml new file mode 100644 index 0000000000..2671cd975d --- /dev/null +++ b/python/sports-league-scheduling/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sports_league_scheduling" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = [ + 'timefold == 999-dev0', + 'fastapi == 0.111.0', + 'pydantic == 2.7.3', + 'uvicorn == 0.30.1', + 'pytest == 8.2.2', + 'httpx == 0.27.0', +] + + +[project.scripts] +run-app = "sports_league_scheduling:main" diff --git a/python/sports-league-scheduling/sports-league-scheduling-screenshot.png b/python/sports-league-scheduling/sports-league-scheduling-screenshot.png new file mode 100644 index 0000000000..cfdfb28274 Binary files /dev/null and b/python/sports-league-scheduling/sports-league-scheduling-screenshot.png differ diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/__init__.py b/python/sports-league-scheduling/src/sports_league_scheduling/__init__.py new file mode 100644 index 0000000000..a9ff525cae --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/__init__.py @@ -0,0 +1,16 @@ +import uvicorn + +from .rest_api import app + + +def main(): + config = uvicorn.Config("sports_league_scheduling:app", + port=8080, + log_config="logging.conf", + use_colors=True) + server = uvicorn.Server(config) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/constraints.py b/python/sports-league-scheduling/src/sports_league_scheduling/constraints.py new file mode 100644 index 0000000000..7a67108d39 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/constraints.py @@ -0,0 +1,132 @@ +from timefold.solver.score import * +from datetime import time +from typing import Final + +from .domain import * + + +MAX_CONSECUTIVE_MATCHES: Final[int] = 4 + + +@constraint_provider +def define_constraints(constraint_factory: ConstraintFactory): + return [ + matches_on_same_day(constraint_factory), + multiple_consecutive_home_matches(constraint_factory), + multiple_consecutive_away_matches(constraint_factory), + repeat_match_on_the_next_day(constraint_factory), + start_to_away_hop(constraint_factory), + home_to_away_hop(constraint_factory), + away_to_away_hop(constraint_factory), + away_to_home_hop(constraint_factory), + away_to_end_hop(constraint_factory), + classic_matches(constraint_factory) + ] + + +def matches_on_same_day(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each_unique_pair(Match, + Joiners.equal(lambda match: match.round.index), + Joiners.filtering(are_teams_overlapping)) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("Matches on the same day")) + +def are_teams_overlapping(match1 : Match, match2:Match) -> bool: + return (match1.home_team == match2.home_team or match1.home_team == match2.away_team + or match1.away_team == match2.home_team or match1.away_team == match2.away_team) + + +def multiple_consecutive_home_matches(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .join(Team, + Joiners.equal(lambda match: match.home_team, lambda team: team)) + .group_by(lambda match, team: team, + ConstraintCollectors.to_consecutive_sequences(lambda match, team: match.round, + lambda match_round: match_round.index)) + .flatten_last(lambda sequences: sequences.getConsecutiveSequences()) + .filter(lambda team, matches: matches.getCount() >= MAX_CONSECUTIVE_MATCHES) + .penalize(HardSoftScore.ONE_HARD, lambda team, matches: matches.getCount()) + .as_constraint("4 or more consecutive home matches")) + + +def multiple_consecutive_away_matches(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .join(Team, + Joiners.equal(lambda match: match.away_team, lambda team: team)) + .group_by(lambda match, team: team, + ConstraintCollectors.to_consecutive_sequences(lambda match, team: match.round, + lambda match_round: match_round.index)) + .flatten_last(lambda sequences: sequences.getConsecutiveSequences()) + .filter(lambda team, matches: matches.getCount() >= MAX_CONSECUTIVE_MATCHES) + .penalize(HardSoftScore.ONE_HARD, lambda team, matches: matches.getCount()) + .as_constraint("4 or more consecutive away matches")) + + +def repeat_match_on_the_next_day(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .if_exists(Match, + Joiners.equal(lambda match: match.home_team, lambda match: match.away_team), + Joiners.equal(lambda match: match.away_team, lambda match: match.home_team), + Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("Repeat match on the next day")) + + +def start_to_away_hop(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .if_not_exists(Round, Joiners.equal(lambda match: match.round.index - 1, + lambda match_round: match_round.index)) + .penalize(HardSoftScore.ONE_SOFT, lambda match: match.away_team.get_distance(match.home_team)) + .as_constraint("Start to away hop")) + + +def home_to_away_hop(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .join(Match, + Joiners.equal(lambda match: match.home_team, lambda match: match.away_team), + Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) + .penalize(HardSoftScore.ONE_SOFT, lambda match, other_match: match.home_team.get_distance(other_match.home_team)) + .as_constraint("Home to away hop")) + + +def away_to_away_hop(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .join(Match, + Joiners.equal(lambda match: match.away_team, lambda match: match.away_team), + Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) + .penalize(HardSoftScore.ONE_SOFT, lambda match, other_match: match.home_team.get_distance(other_match.home_team)) + .as_constraint("Away to away hop")) + + +def away_to_home_hop(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .join(Match, + Joiners.equal(lambda match: match.away_team, lambda match: match.home_team), + Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) + .penalize(HardSoftScore.ONE_SOFT, lambda match, other_match: match.home_team.get_distance(match.away_team)) + .as_constraint("Away to home hop")) + + +def away_to_end_hop(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .if_not_exists(Round, Joiners.equal(lambda match: match.round.index + 1, + lambda match_round: match_round.index)) + .penalize(HardSoftScore.ONE_SOFT, lambda match: match.home_team.get_distance(match.away_team)) + .as_constraint("Away to end hop")) + + +def classic_matches(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Match) + .filter(lambda match: match.classic_match and not match.round.weekend_or_holiday) + .penalize(HardSoftScore.of_soft(1000)) + .as_constraint("Classic matches played on weekends or holidays")) diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/demo_data.py b/python/sports-league-scheduling/src/sports_league_scheduling/demo_data.py new file mode 100644 index 0000000000..7c62b43c7c --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/demo_data.py @@ -0,0 +1,126 @@ +import json +from random import Random +from datetime import datetime, time, timedelta +from typing import List, Callable + +from .domain import * + +random = Random(0) +DISTANCE_IN_KM = [ + [0, 2163, 2163, 2160, 2156, 2156, 2163, 340, 1342, 512, 3038, 1526, 2054, 2054], + [2163, 0, 11, 50, 813, 813, 11, 1967, 842, 1661, 1139, 1037, 202, 202], + [2163, 11, 0, 50, 813, 813, 11, 1967, 842, 1661, 1139, 1037, 202, 202], + [2160, 50, 50, 0, 862, 862, 50, 1957, 831, 1655, 1180, 1068, 161, 161], + [2160, 813, 813, 862, 0, 1, 813, 2083, 1160, 1741, 910, 644, 600, 600], + [2160, 813, 813, 862, 1, 0, 813, 2083, 1160, 1741, 910, 644, 600, 600], + [2163, 11, 11, 50, 813, 813, 0, 1967, 842, 1661, 1139, 1037, 202, 202], + [340, 1967, 1967, 1957, 2083, 2083, 1967, 0, 1126, 341, 2926, 1490, 1836, 1836], + [1342, 842, 842, 831, 1160, 1160, 842, 1126, 0, 831, 1874, 820, 714, 714], + [512, 1661, 1661, 1655, 1741, 1741, 1661, 341, 831, 0, 2589, 1151, 1545, 1545], + [3038, 1139, 1139, 1180, 910, 910, 1139, 2926, 1874, 2589, 0, 1552, 1340, 1340], + [1526, 1037, 1037, 1068, 644, 644, 1037, 1490, 820, 1151, 1552, 0, 1077, 1077], + [2054, 202, 202, 161, 600, 600, 202, 1836, 714, 1545, 1340, 1077, 0, 14], + [2054, 202, 202, 161, 600, 600, 202, 1836, 714, 1545, 1340, 1077, 14, 0], +] + + +def id_generator(): + current = 0 + while True: + yield str(current) + current += 1 + + +def generate_rounds(count_rounds : int) -> List[Round]: + today = datetime.now() + rounds = [Round(index=i, weekend_or_holiday=False) for i in range(count_rounds)] + + # Mark weekend rounds as important + for round_obj in rounds: + future_date = today + timedelta(days=round_obj.index) + if future_date.weekday() in (5, 6): # Saturday or Sunday + round_obj.weekend_or_holiday = True + + return rounds + + +def generate_teams() -> List[Team]: + team_names = [ + "Cruzeiro", "Argentinos Jr.", "Boca Juniors", "Estudiantes", "Independente", + "Racing", "River Plate", "Flamengo", "Gremio", "Santos", + "Colo-Colo", "Olimpia", "Nacional", "Penharol" + ] + + teams = [Team(id=str(i + 1), name=name, distance_to_team={}) for i, name in enumerate(team_names)] + + # Assign distances + for i, team in enumerate(teams): + team.distance_to_team = { + teams[j].id: DISTANCE_IN_KM[i][j] + for j in range(len(teams)) + if i != j + } + + return teams + +def generate_matches(teams: List[Team]) -> List[Match]: + reciprocal_match = None + matches = [ + Match(id=f'{team1.id}-{team2.id}', home_team=team1, away_team=team2, classic_match=False, round=None) + for team1 in teams + for team2 in teams + if team1 != team2 + ] + + # 5% classic matches + apply_random_value( + count=int(len(matches) * 0.05), + values = matches, + filter_func = lambda match_league: not match_league.classic_match, + consumer_func = lambda match_league: setattr(match_league, 'classic_match', True) + ) + + # Ensure reciprocity for classic matches + for match in matches: + if match.classic_match: + reciprocal_match = next((m for m in matches if m.home_team == match.away_team and m.away_team == match.home_team), None) + if reciprocal_match: + reciprocal_match.classic_match = True + + return matches + + +def apply_random_value(count: int, values: List, filter_func: Callable, consumer_func: Callable) -> None: + filtered_values = [value for value in values if filter_func(value)] + size = len(filtered_values) + + for _ in range(count): + if size > 0: + selected_value = random.choice(filtered_values) + consumer_func(selected_value) + filtered_values.remove(selected_value) + size -= 1 + else: + break + + +def generate_demo_data() -> LeagueSchedule: + count_rounds = 32 + # Rounds + rounds = generate_rounds(count_rounds) + # Teams + teams = generate_teams() + # Matches + matches = generate_matches(teams) + + # Create Schedule + schedule = LeagueSchedule( + id="demo-schedule", + rounds=rounds, + teams=teams, + matches=matches, + score=None, + solver_status=None + ) + + return schedule \ No newline at end of file diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/domain.py b/python/sports-league-scheduling/src/sports_league_scheduling/domain.py new file mode 100644 index 0000000000..4ef22a45ed --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/domain.py @@ -0,0 +1,119 @@ +from timefold.solver import SolverStatus +from timefold.solver.domain import (planning_entity, planning_solution, PlanningId, PlanningVariable, + PlanningEntityCollectionProperty, + ProblemFactCollectionProperty, ValueRangeProvider, + PlanningScore) +from timefold.solver.score import HardSoftScore +from typing import Dict, List, Any, Annotated + +from .json_serialization import * + + +class Team(JsonDomainBase): + id: str + name: str + distance_to_team: Annotated[Dict[str, int], + DistanceToTeamValidator, + Field(default_factory=dict)] + + + def get_distance(self, other_team: "Team") -> int: + """ + Get the distance to another team. + """ + if not isinstance(other_team, Team): + raise TypeError(f"Expected a Team, got {type(other_team)}") + return self.distance_to_team.get(other_team.id, 0) + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, Team): + return False + return self.id == other.id + + def __hash__(self): + return hash(self.id) + + def __str__(self): + return self.id + + def __repr__(self): + return f'Team({self.id}, {self.name}, {self.distance_to_team})' + + +class Round(JsonDomainBase): + index: Annotated[int, PlanningId] + # Rounds scheduled on weekends and holidays. It's common for classic matches to be scheduled on weekends or holidays. + weekend_or_holiday: Annotated[bool, Field(default=False)] + + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, Round): + return False + return self.index == other.index + + def __hash__(self): + return 31 * self.index + + def __str__(self): + return f'Round-{self.index}' + + def __repr__(self): + return f'Round({self.index}, {self.weekendOrHoliday})' + + +@planning_entity +class Match(JsonDomainBase): + id: Annotated[str, PlanningId] + home_team: Annotated[Team, + IdStrSerializer, + TeamDeserializer] + away_team: Annotated[Team, + IdStrSerializer, + TeamDeserializer] + # A classic/important match can impact aspects like revenue (e.g., derby) + classic_match: Annotated[bool, Field(default=False)] + round: Annotated[Round | None, + PlanningVariable, + IdIntSerializer, + RoundDeserializer, + Field(default=None)] + + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, Match): + return False + return self.id == other.id + + def __hash__(self): + return hash(self.id) + + def __str__(self): + return f'{self.home_team} + {self.away_team}' + + def __repr__(self): + return f'Match({self.id}, {self.home_team}, {self.away_team}, {self.classic_match})' + + +@planning_solution +class LeagueSchedule(JsonDomainBase): + id: str + rounds: Annotated[list[Round], + ProblemFactCollectionProperty, + ValueRangeProvider] + teams: Annotated[list[Team], + ProblemFactCollectionProperty, + ValueRangeProvider] + matches: Annotated[list[Match], + PlanningEntityCollectionProperty] + score: Annotated[HardSoftScore | None, + PlanningScore, + ScoreSerializer, + ScoreValidator, + Field(default=None)] + solver_status: Annotated[SolverStatus | None, Field(default=SolverStatus.NOT_SOLVING)] \ No newline at end of file diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/json_serialization.py b/python/sports-league-scheduling/src/sports_league_scheduling/json_serialization.py new file mode 100644 index 0000000000..e6784cbd96 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/json_serialization.py @@ -0,0 +1,71 @@ +from timefold.solver.score import HardSoftScore +from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, BeforeValidator, ValidationInfo +from pydantic.alias_generators import to_camel +from typing import Any, Dict + + +def make_list_item_validator(key: str): + def validator(v: Any, info: ValidationInfo) -> Any: + if v is None: + return None + + if not isinstance(v, (str, int)): + return v + + if not info.context or key not in info.context: + raise ValueError(f"Context is missing or does not contain key '{key}'.") + + context_data = info.context.get(key) + if v not in context_data: + raise ValueError(f"Value '{v}' not found in context for key '{key}'.") + + return context_data[v] + + return BeforeValidator(validator) + + +RoundDeserializer = make_list_item_validator('rounds') +TeamDeserializer = make_list_item_validator('teams') + +IdStrSerializer = PlainSerializer( + lambda item: item.id if item is not None else None, + return_type=str | None +) +IdIntSerializer = PlainSerializer( + lambda item: item.index if item is not None else None, + return_type=int | None +) +ScoreSerializer = PlainSerializer(lambda score: str(score) if score is not None else None, + return_type=str | None) + + +def validate_score(v: Any, info: ValidationInfo) -> Any: + if isinstance(v, HardSoftScore) or v is None: + return v + if isinstance(v, str): + return HardSoftScore.parse(v) + raise ValueError('"score" should be a string') + + +def validate_distance_to_team(value: Any, info: ValidationInfo) -> Dict[str, int]: + if not isinstance(value, dict): + raise ValueError("distance_to_team must be a dictionary.") + + for key, val in value.items(): + if not isinstance(key, str): + raise ValueError(f"Key {key} in distance_to_team must be a Team instance.") + if not isinstance(val, int): + raise ValueError(f"Value for {key} must be an integer.") + + return value + + +ScoreValidator = BeforeValidator(validate_score) +DistanceToTeamValidator = BeforeValidator(validate_distance_to_team) + +class JsonDomainBase(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + from_attributes=True, + ) diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/rest_api.py b/python/sports-league-scheduling/src/sports_league_scheduling/rest_api.py new file mode 100644 index 0000000000..d7cf195041 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/rest_api.py @@ -0,0 +1,111 @@ +from fastapi import FastAPI, Depends, Request +from fastapi.staticfiles import StaticFiles +from typing import Annotated, Final +from uuid import uuid4 +from datetime import datetime +from .domain import * +from .score_analysis import * +from .demo_data import generate_demo_data +from .solver import solver_manager, solution_manager + +app = FastAPI(docs_url='/q/swagger-ui') +MAX_JOBS_CACHE_SIZE: Final[int] = 2 +data_sets: Dict[str, dict] = {} + + +@app.get("/demo-data") +async def get_demo_data(): + return generate_demo_data() + + +async def setup_context(request: Request) -> LeagueSchedule: + json = await request.json() + return LeagueSchedule.model_validate(json, + context={ + 'rounds': { + match_round['index']: Round.model_validate(match_round) for + match_round in json.get('rounds', []) + }, + 'teams': { + team['id']: Team.model_validate(team) for + team in json.get('teams', []) + }, + }) + + +def clean_jobs(): + """ + The method retains only the records of the last MAX_JOBS_CACHE_SIZE completed jobs by removing the oldest ones. + """ + global data_sets + if len(data_sets) <= MAX_JOBS_CACHE_SIZE: + return + + completed_jobs = [ + (job_id, job_data) + for job_id, job_data in data_sets.items() + if job_data["schedule"] is not None + ] + + completed_jobs.sort(key=lambda job: job[1]["created_at"]) + + for job_id, _ in completed_jobs[:len(completed_jobs) - MAX_JOBS_CACHE_SIZE]: + del data_sets[job_id] + + +def update_league_schedule(problem_id: str, league_schedule: LeagueSchedule): + global data_sets + data_sets[problem_id]["schedule"] = league_schedule + + +@app.post("/schedules") +async def solve_schedule(league_schedule: Annotated[LeagueSchedule, Depends(setup_context)]) -> str: + job_id = str(uuid4()) + data_sets[job_id] = { + "schedule": league_schedule, + "created_at": datetime.now(), + "exception": None, + } + solver_manager.solve_and_listen(job_id, league_schedule, + lambda solution: update_league_schedule(job_id, solution)) + clean_jobs() + return job_id + + +@app.get("/schedules/{problem_id}") +async def get_league_schedule(problem_id: str) -> LeagueSchedule: + league_schedule = data_sets[problem_id]["schedule"] + return league_schedule.model_copy(update={ + 'solver_status': solver_manager.get_solver_status(problem_id) + }) + + +@app.get("/schedules/{job_id}/status") +async def get_schedule_status(job_id: str) -> dict: + league_schedule = data_sets[job_id]["schedule"] + return {"solver_status": league_schedule.solver_status} + + +@app.put("/schedules/analyze") +async def analyze_timetable(league_schedule: Annotated[LeagueSchedule, Depends(setup_context)]) -> dict: + return {'constraints': [ConstraintAnalysisDTO( + name=constraint.constraint_name, + weight=constraint.weight, + score=constraint.score, + matches=[ + MatchAnalysisDTO( + name=match.constraint_ref.constraint_name, + score=match.score, + justification=match.justification + ) + for match in constraint.matches + ] + ) for constraint in solution_manager.analyze(league_schedule).constraint_analyses]} + + +@app.delete("/schedules/{problem_id}") +async def stop_solving(problem_id: str) -> None: + solver_manager.terminate_early(problem_id) + + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/score_analysis.py b/python/sports-league-scheduling/src/sports_league_scheduling/score_analysis.py new file mode 100644 index 0000000000..3884952fe7 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/score_analysis.py @@ -0,0 +1,18 @@ +from timefold.solver.score import ConstraintJustification +from dataclasses import dataclass, field + +from .json_serialization import * +from .domain import * + + +class MatchAnalysisDTO(JsonDomainBase): + name: str + score: Annotated[HardSoftScore, ScoreSerializer] + justification: object + + +class ConstraintAnalysisDTO(JsonDomainBase): + name: str + weight: Annotated[HardSoftScore, ScoreSerializer] + matches: list[MatchAnalysisDTO] + score: Annotated[HardSoftScore, ScoreSerializer] diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/solver.py b/python/sports-league-scheduling/src/sports_league_scheduling/solver.py new file mode 100644 index 0000000000..96275ece1a --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/solver.py @@ -0,0 +1,21 @@ +from timefold.solver import SolverManager, SolverFactory, SolutionManager +from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig, + TerminationConfig, Duration) + +from .domain import * +from .constraints import define_constraints + + +solver_config = SolverConfig( + solution_class=LeagueSchedule, + entity_class_list=[Match], + score_director_factory_config=ScoreDirectorFactoryConfig( + constraint_provider_function=define_constraints + ), + termination_config=TerminationConfig( + spent_limit=Duration(seconds=30) + ) +) + +solver_manager = SolverManager.create(SolverFactory.create(solver_config)) +solution_manager = SolutionManager.create(solver_manager) diff --git a/python/sports-league-scheduling/static/app.js b/python/sports-league-scheduling/static/app.js new file mode 100644 index 0000000000..fea847c161 --- /dev/null +++ b/python/sports-league-scheduling/static/app.js @@ -0,0 +1,322 @@ +let autoRefreshIntervalId = null; +const formatter = JSJoda.DateTimeFormatter.ofPattern("MM/dd/YYYY HH:mm").withLocale(JSJodaLocale.Locale.ENGLISH); + +const zoomMin = 1000 * 60 * 60 * 24 // 1 day in milliseconds +const zoomMax = 1000 * 60 * 60 * 24 * 7 * 4 // 2 weeks in milliseconds + +const byTimelineOptions = { + timeAxis: {scale: "day"}, + orientation: {axis: "top"}, + stack: false, + xss: {disabled: true}, // Items are XSS safe through JQuery + zoomMin: zoomMin, + showCurrentTime: false, +}; + +const byTeamPanel = document.getElementById("byTeamPanel"); +let byTeamGroupData = new vis.DataSet(); +let byTeamItemData = new vis.DataSet(); +let byTeamTimeline = new vis.Timeline(byTeamPanel, byTeamItemData, byTeamGroupData, byTimelineOptions); + +let scheduleId = null; +let loadedSchedule = null; +let viewType = "T"; + +$(document).ready(function () { + replaceQuickstartTimefoldAutoHeaderFooter(); + + $("#solveButton").click(function () { + solve(); + }); + $("#stopSolvingButton").click(function () { + stopSolving(); + }); + $("#analyzeButton").click(function () { + analyze(); + }); + $("#byTeamTab").click(function () { + viewType = "T"; + refreshSchedule(); + }); + + setupAjax(); + refreshSchedule(); +}); + +function setupAjax() { + $.ajaxSetup({ + headers: { + 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job + } + }); + + // Extend jQuery to support $.put() and $.delete() + jQuery.each(["put", "delete"], function (i, method) { + jQuery[method] = function (url, data, callback, type) { + if (jQuery.isFunction(data)) { + type = type || callback; + callback = data; + data = undefined; + } + return jQuery.ajax({ + url: url, type: method, dataType: type, data: data, success: callback + }); + }; + }); +} + +function refreshSchedule() { + let path = "/schedules/" + scheduleId; + if (scheduleId === null) { + path = "/demo-data"; + } + + $.getJSON(path, function (schedule) { + loadedSchedule = schedule; + $('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule)); + renderSchedule(schedule); + }) + .fail(function (xhr, ajaxOptions, thrownError) { + showError("Getting the schedule has failed.", xhr); + refreshSolvingButtons(false); + }); +} + +function renderSchedule(schedule) { + refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); + $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); + + if (viewType === "T") { + renderScheduleByTeam(schedule); + } +} + +function renderScheduleByTeam(schedule) { + const unassigned = $("#unassigned"); + unassigned.children().remove(); + byTeamGroupData.clear(); + byTeamItemData.clear(); + + const teamMap = new Map(); + $.each(schedule.teams.sort((t1, t2) => t1.name.localeCompare(t2.name)), (_, team) => { + teamMap.set(team.id, team); + let content = `<div class="d-flex flex-column"><div><h5 class="card-title mb-1">${team.name}</h5></div>`; + byTeamGroupData.add({ + id: team.id, + content: content, + }); + }); + + + const currentDate = JSJoda.LocalDate.now(); + $.each(schedule.matches, (_, match) => { + const homeTeam = teamMap.get(match.homeTeam); + const awayTeam = teamMap.get(match.awayTeam); + if (match.round == null) { + const unassignedElement = $(`<div class="card-body" style="background-color: ${match.classicMatch ? '#198754' : '#97b0f8'}"/>`) + .append($(`<h5 class="card-title mb-1"/>`).text(`${homeTeam.name} x ${awayTeam.name}`)); + + unassigned.append($(`<div class="pl-1"/>`).append($(`<div class="card"/>`).append(unassignedElement))); + } else { + const byHomeTeamElement = $("<div />").append($("<div class='d-flex justify-content-center' />").append($(`<h5 class="card-title mb-1"/>`).text(awayTeam.name))).append($(`<small class="ms-2 mt-1 card-text text-muted align-bottom float-end"/>`).append("<span class='fas fa-solid fa-home' title='Home Match' />")); + const byAwayTeamElement = $("<div />").append($("<div class='d-flex justify-content-center' />").append($(`<h5 class="card-title mb-1"/>`).text(homeTeam.name))).append($(`<small class="ms-2 mt-1 card-text text-muted align-bottom float-end"/>`).append("<span class='fas fa-plane-departure' title='Away Match' />")); + byTeamItemData.add({ + id: `${match.id}-1`, + group: homeTeam.id, + content: byHomeTeamElement.html(), + start: currentDate.plusDays(match.round).toString(), + end: currentDate.plusDays(match.round + 1).toString(), + style: `background-color: ${match.classicMatch ? '#198754CF' : '#97b0f8'}` + }); + byTeamItemData.add({ + id: `${match.id}-2`, + group: awayTeam.id, + content: byAwayTeamElement.html(), + start: currentDate.plusDays(match.round).toString(), + end: currentDate.plusDays(match.round + 1).toString(), + style: `background-color: ${match.classicMatch ? '#198754CF' : '#97b0f8'}` + }); + } + }); + + byTeamTimeline.setWindow(JSJoda.LocalDate.now().toString(), JSJoda.LocalDate.now().plusDays(7).toString()); +} + +function solve() { + $.post("/schedules", JSON.stringify(loadedSchedule), function (data) { + scheduleId = data; + refreshSolvingButtons(true); + }).fail(function (xhr, ajaxOptions, thrownError) { + showError("Start solving failed.", xhr); + refreshSolvingButtons(false); + }, "text"); +} + +function analyze() { + new bootstrap.Modal("#scoreAnalysisModal").show() + const scoreAnalysisModalContent = $("#scoreAnalysisModalContent"); + scoreAnalysisModalContent.children().remove(); + if (loadedSchedule.score == null || loadedSchedule.score.indexOf('init') != -1) { + scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button."); + } else { + $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`); + $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) { + let constraints = scoreAnalysis.constraints; + constraints.sort((a, b) => { + let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score); + if (aComponents.hard < 0 && bComponents.hard > 0) return -1; + if (aComponents.hard > 0 && bComponents.soft < 0) return 1; + if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) { + return -1; + } else { + if (aComponents.medium < 0 && bComponents.medium > 0) return -1; + if (aComponents.medium > 0 && bComponents.medium < 0) return 1; + if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) { + return -1; + } else { + if (aComponents.soft < 0 && bComponents.soft > 0) return -1; + if (aComponents.soft > 0 && bComponents.soft < 0) return 1; + + return Math.abs(bComponents.soft) - Math.abs(aComponents.soft); + } + } + }); + constraints.map((e) => { + let components = getScoreComponents(e.weight); + e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft'); + e.weight = components[e.type]; + let scores = getScoreComponents(e.score); + e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft); + }); + scoreAnalysis.constraints = constraints; + + scoreAnalysisModalContent.children().remove(); + scoreAnalysisModalContent.text(""); + + const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'}); + const analysisTHead = $(`<thead/>`).append($(`<tr/>`) + .append($(`<th></th>`)) + .append($(`<th>Constraint</th>`).css({textAlign: 'left'})) + .append($(`<th>Type</th>`)) + .append($(`<th># Matches</th>`)) + .append($(`<th>Weight</th>`)) + .append($(`<th>Score</th>`)) + .append($(`<th></th>`))); + analysisTable.append(analysisTHead); + const analysisTBody = $(`<tbody/>`) + $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => { + let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : ''; + if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : ''; + + let row = $(`<tr/>`); + row.append($(`<td/>`).html(icon)) + .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'})) + .append($(`<td/>`).text(constraintAnalysis.type)) + .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`)) + .append($(`<td/>`).text(constraintAnalysis.weight)) + .append($(`<td/>`).text(constraintAnalysis.implicitScore)); + analysisTBody.append(row); + row.append($(`<td/>`)); + }); + analysisTable.append(analysisTBody); + scoreAnalysisModalContent.append(analysisTable); + }).fail(function (xhr, ajaxOptions, thrownError) { + showError("Analyze failed.", xhr); + }, "text"); + } +} + +function getScoreComponents(score) { + let components = {hard: 0, medium: 0, soft: 0}; + + $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => { + components[parts[2]] = parseInt(parts[1], 10); + }); + + return components; +} + +function refreshSolvingButtons(solving) { + if (solving) { + $("#solveButton").hide(); + $("#stopSolvingButton").show(); + if (autoRefreshIntervalId == null) { + autoRefreshIntervalId = setInterval(refreshSchedule, 2000); + } + } else { + $("#solveButton").show(); + $("#stopSolvingButton").hide(); + if (autoRefreshIntervalId != null) { + clearInterval(autoRefreshIntervalId); + autoRefreshIntervalId = null; + } + } +} + +function stopSolving() { + $.delete("/schedules/" + scheduleId, function () { + refreshSolvingButtons(false); + refreshSchedule(); + }).fail(function (xhr, ajaxOptions, thrownError) { + showError("Stop solving failed.", xhr); + }); +} + +function copyTextToClipboard(id) { + const text = $("#" + id).text().trim(); + + const dummy = document.createElement("textarea"); + document.body.appendChild(dummy); + dummy.value = text; + dummy.select(); + document.execCommand("copy"); + document.body.removeChild(dummy); +} + +// TODO: move to the webjar +function replaceQuickstartTimefoldAutoHeaderFooter() { + const timefoldHeader = $("header#timefold-auto-header"); + if (timefoldHeader != null) { + timefoldHeader.addClass("bg-black") + timefoldHeader.append($(`<div class="container-fluid"> + <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3"> + <a class="navbar-brand" href="https://timefold.ai"> + <img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200"> + </a> + <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + <div class="collapse navbar-collapse" id="navbarNav"> + <ul class="nav nav-pills"> + <li class="nav-item active" id="navUIItem"> + <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button">Demo UI</button> + </li> + <li class="nav-item" id="navRestItem"> + <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button">Guide</button> + </li> + <li class="nav-item" id="navOpenApiItem"> + <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button">REST API</button> + </li> + </ul> + </div> + </nav> + </div>`)); + } + + const timefoldFooter = $("footer#timefold-auto-footer"); + if (timefoldFooter != null) { + timefoldFooter.append($(`<footer class="bg-black text-white-50"> + <div class="container"> + <div class="hstack gap-3 p-4"> + <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div> + <div class="vr"></div> + <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div> + <div class="vr"></div> + <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div> + <div class="vr"></div> + <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div> + </div> + </div> + </footer>`)); + } +} diff --git a/python/sports-league-scheduling/static/index.html b/python/sports-league-scheduling/static/index.html new file mode 100644 index 0000000000..94b076615a --- /dev/null +++ b/python/sports-league-scheduling/static/index.html @@ -0,0 +1,159 @@ +<!DOCTYPE> +<html lang="en"> +<head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type"> + <meta content="width=device-width, initial-scale=1" name="viewport"> + <title>Sports League Scheduling - Timefold Solver for Python</title> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"/> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"/> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css" + integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous"> + <link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/> + <link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml"> + <style> + .vis-time-axis .vis-grid.vis-saturday, + .vis-time-axis .vis-grid.vis-sunday { + background: #D3D7CFFF; + } + + .vis-item-content { + width: 100%; + } + </style> +</head> +<body> +<header id="timefold-auto-header"> + <!-- Filled in by app.js --> +</header> +<div class="tab-content"> + <div id="demo" class="tab-pane fade show active container"> + <div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true"> + <div id="notificationPanel" style="position: absolute; top: .5rem;"></div> + </div> + <h1>Sports League Scheduling Solver</h1> + <p>Generate the optimal schedule for your sports league matches.</p> + + <div class="mb-2"> + <button id="solveButton" type="button" class="btn btn-success"> + <span class="fas fa-play"></span> Solve + </button> + <button id="stopSolvingButton" type="button" class="btn btn-danger"> + <span class="fas fa-stop"></span> Stop solving + </button> + <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span> + <button id="analyzeButton" type="button" class="ms-2 btn btn-secondary"> + <span class="fas fa-question"></span> + </button> + + <div class="float-end"> + <ul class="nav nav-pills" role="tablist"> + <li class="nav-item" role="presentation"> + <button class="nav-link active" id="byTeamTab" data-bs-toggle="tab" + data-bs-target="#byTeamPanel" type="button" role="tab" aria-controls="byTeamPanel" + aria-selected="true">By Team + </button> + </li> + </ul> + </div> + </div> + <div class="tab-content"> + <div class="tab-pane fade show active" id="byTeamPanel" role="tabpanel" aria-labelledby="byTeamTab"> + <div id="teamVisualization"></div> + </div> + </div> + + <h2>Unassigned</h2> + <div id="unassigned" class="row row-cols-4 g-3 mb-4"></div> + </div> + + <div id="rest" class="tab-pane fade container-fluid"> + <h1>REST API Guide</h1> + + <h2>Sports League Scheduling solver integration via cURL</h2> + + <h3>1. Download demo data</h3> + <pre> + <button class="btn btn-outline-dark btn-sm float-end" + onclick="copyTextToClipboard('curl1')">Copy</button> + <code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data -o sample.json</code> + </pre> + + <h3>2. Post the sample data for solving</h3> + <p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p> + <pre> + <button class="btn btn-outline-dark btn-sm float-end" + onclick="copyTextToClipboard('curl2')">Copy</button> + <code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code> + </pre> + + <h3>3. Get the current status and score</h3> + <pre> + <button class="btn btn-outline-dark btn-sm float-end" + onclick="copyTextToClipboard('curl3')">Copy</button> + <code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code> + </pre> + + <h3>4. Get the complete solution</h3> + <pre> + <button class="btn btn-outline-dark btn-sm float-end" + onclick="copyTextToClipboard('curl4')">Copy</button> + <code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId} -o solution.json</code> + </pre> + + <h3>5. Fetch the analysis of the solution</h3> + <pre> + <button class="btn btn-outline-dark btn-sm float-end" + onclick="copyTextToClipboard('curl5')">Copy</button> + <code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code> + </pre> + + <h3>6. Terminate solving early</h3> + <pre> + <button class="btn btn-outline-dark btn-sm float-end" + onclick="copyTextToClipboard('curl6')">Copy</button> + <code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code> + </pre> + </div> + + <div id="openapi" class="tab-pane fade container-fluid"> + <h1>REST API Reference</h1> + <div class="ratio ratio-1x1"> + <!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" --> + <iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe> + </div> + </div> +</div> +<footer id="timefold-auto-footer"></footer> +<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" + aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg modal-dialog-scrollable"> + <div class="modal-content"> + <div class="modal-header"> + <h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span + id="scoreAnalysisScoreLabel"></span></h1> + + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body" id="scoreAnalysisModalContent"> + <!-- Filled in by app.js --> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button> + </div> + </div> + </div> +</div> + +<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script> +<script src=" +https://cdn.jsdelivr.net/npm/@js-joda/locale@4.15.1/dist/prebuilt/en/index.min.js +"></script> +<script src="/webjars/timefold/js/timefold-webui.js"></script> +<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js" + integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script> +<script src="/app.js"></script> +</body> +</html> diff --git a/python/sports-league-scheduling/static/webjars/timefold/css/timefold-webui.css b/python/sports-league-scheduling/static/webjars/timefold/css/timefold-webui.css new file mode 100644 index 0000000000..0d729db03d --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/css/timefold-webui.css @@ -0,0 +1,60 @@ +:root { + /* Keep in sync with .navbar height on a large screen. */ + --ts-navbar-height: 109px; + + --ts-violet-1-rgb: #3E00FF; + --ts-violet-2-rgb: #3423A6; + --ts-violet-3-rgb: #2E1760; + --ts-violet-4-rgb: #200F4F; + --ts-violet-5-rgb: #000000; /* TODO FIXME */ + --ts-violet-dark-1-rgb: #b6adfd; + --ts-violet-dark-2-rgb: #c1bbfd; + --ts-gray-rgb: #666666; + --ts-white-rgb: #FFFFFF; + --ts-light-rgb: #F2F2F2; + --ts-gray-border: #c5c5c5; + + --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */ + --bs-body-bg: var(--ts-light-rgb); /* link to html bg */ + --bs-link-color: var(--ts-violet-1-rgb); + --bs-link-hover-color: var(--ts-violet-2-rgb); + + --bs-navbar-color: var(--ts-white-rgb); + --bs-navbar-hover-color: var(--ts-white-rgb); + --bs-nav-link-font-size: 18px; + --bs-nav-link-font-weight: 400; + --bs-nav-link-color: var(--ts-white-rgb); + --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb); +} +.btn { + --bs-btn-border-radius: 1.5rem; +} +.btn-primary { + --bs-btn-bg: var(--ts-violet-1-rgb); + --bs-btn-border-color: var(--ts-violet-1-rgb); + --bs-btn-hover-bg: var(--ts-violet-2-rgb); + --bs-btn-hover-border-color: var(--ts-violet-2-rgb); + --bs-btn-active-bg: var(--ts-violet-2-rgb); + --bs-btn-active-border-bg: var(--ts-violet-2-rgb); + --bs-btn-disabled-bg: var(--ts-violet-1-rgb); + --bs-btn-disabled-border-color: var(--ts-violet-1-rgb); +} +.btn-outline-primary { + --bs-btn-color: var(--ts-violet-1-rgb); + --bs-btn-border-color: var(--ts-violet-1-rgb); + --bs-btn-hover-bg: var(--ts-violet-1-rgb); + --bs-btn-hover-border-color: var(--ts-violet-1-rgb); + --bs-btn-active-bg: var(--ts-violet-1-rgb); + --bs-btn-active-border-color: var(--ts-violet-1-rgb); + --bs-btn-disabled-color: var(--ts-violet-1-rgb); + --bs-btn-disabled-border-color: var(--ts-violet-1-rgb); +} +.navbar-dark { + --bs-link-color: var(--ts-violet-dark-1-rgb); + --bs-link-hover-color: var(--ts-violet-dark-2-rgb); + --bs-navbar-color: var(--ts-white-rgb); + --bs-navbar-hover-color: var(--ts-white-rgb); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb); +} diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-favicon.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-favicon.svg new file mode 100644 index 0000000000..f5bece2d39 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-favicon.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" + width="1160.000000pt" height="1160.000000pt" viewBox="0 0 1160.000000 1160.000000" + preserveAspectRatio="xMidYMid meet"> +<metadata> +Created by potrace 1.14, written by Peter Selinger 2001-2017 +</metadata> +<g transform="translate(0.000000,1160.000000) scale(0.100000,-0.100000)" +fill="#000000" stroke="none"> +<path d="M2000 8800 l0 -1000 -637 -2 -637 -3 -362 -995 -362 -995 999 -3 999 +-2 0 -2000 0 -2000 1174 0 c1269 0 1253 -1 1413 54 246 84 455 265 568 490 25 +50 146 370 270 711 124 341 237 637 252 657 15 22 46 48 76 63 l51 25 1607 0 +c1522 0 1607 1 1612 17 3 10 162 450 355 978 192 528 352 970 356 983 l8 23 +-1389 -3 -1388 -3 -77 -22 c-187 -53 -315 -124 -444 -247 -97 -91 -184 -209 +-228 -307 -13 -30 -124 -331 -247 -669 -141 -388 -234 -631 -254 -659 -16 -25 +-48 -54 -70 -65 -39 -21 -54 -21 -762 -24 l-723 -2 0 1000 0 1000 818 0 c675 +0 833 3 909 15 307 50 571 240 718 518 25 47 141 351 270 707 125 344 238 642 +251 662 13 21 43 50 66 65 l42 28 1820 5 1820 5 362 995 362 995 -1546 3 +c-1665 2 -1623 3 -1782 -49 -275 -90 -504 -302 -613 -569 -19 -47 -131 -350 +-248 -674 -232 -637 -237 -648 -323 -687 -39 -18 -104 -19 -1483 -19 l-1443 0 +0 1000 0 1000 -1080 0 -1080 0 0 -1000z"/> +</g> +</svg> diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg new file mode 100644 index 0000000000..26aa96ab2f --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 478.51 100"><defs><style>.cls-1{fill:#fff;}</style></defs><g id="Layer_1-2"><g><path class="cls-1" d="m52,25V0h-27v25h-15.9L0,50h25v50h28.35c5.25,0,9.95-3.29,11.75-8.22l5.46-15.01s.64-1.76,2.52-1.76h39.62l9.1-25h-32.75c-5.25,0-9.95,3.29-11.75,8.22l-5.46,15.01s-.64,1.76-2.52,1.76h-17.33v-25h19.55c5.25,0,9.95-3.29,11.75-8.22l5.46-15.01s.64-1.76,2.52-1.76h44.62L145,0h-37.75c-5.25,0-9.95,3.29-11.75,8.22l-5.46,15.01s-.64,1.76-2.52,1.76h-35.53Z"/><g><path class="cls-1" d="m214.81,66.3l-2.12,5.83c-.63,1.72-2.26,2.86-4.09,2.86h-9.26c-2.4,0-4.34-1.94-4.34-4.34v-27.5h-6.49v-7.17h6.49v-11h10.71v11h7.85v7.17h-7.85v23.14h9.1Z"/><path class="cls-1" d="m370.25,28.7h-7.39v7.3h7.39v7.17h-7.39v31.84h-10.71v-31.84h-6.49v-7.17h6.49v-11.66c0-2.4,1.94-4.34,4.34-4.34h16.93l-3.16,8.7Z"/><path class="cls-1" d="m218.99,30v-10h10.71v10h-10.71Zm10.71,6v39h-10.71v-39h10.71Z"/><path class="cls-1" d="m248.56,36v5.28h.15c1.41-2.01,3.1-3.57,5.09-4.68,1.99-1.11,4.26-1.66,6.83-1.66s4.72.48,6.75,1.43c2.04.96,3.58,2.64,4.64,5.05,1.16-1.71,2.73-3.22,4.72-4.53,1.99-1.31,4.34-1.96,7.05-1.96,2.06,0,3.97.25,5.73.76,1.76.5,3.27,1.31,4.53,2.41,1.26,1.11,2.24,2.55,2.94,4.34.7,1.79,1.06,3.94,1.06,6.45v26.1h-10.71v-22.1c0-1.31-.05-2.54-.15-3.7-.1-1.16-.38-2.16-.83-3.02-.45-.85-1.12-1.53-2-2.04-.88-.5-2.08-.75-3.58-.75s-2.73.29-3.66.87c-.93.58-1.66,1.33-2.19,2.26-.53.93-.88,1.99-1.06,3.17-.18,1.18-.26,2.38-.26,3.58v21.73h-10.71v-21.88c0-1.16-.03-2.3-.08-3.43-.05-1.13-.26-2.17-.64-3.13-.38-.95-1.01-1.72-1.89-2.3-.88-.58-2.18-.87-3.89-.87-.5,0-1.17.11-2,.34-.83.23-1.63.65-2.41,1.28-.78.63-1.45,1.53-2,2.71-.55,1.18-.83,2.73-.83,4.64v22.63h-10.71v-39h10.11Z"/><path class="cls-1" d="m317.97,65.64c1.61,1.56,3.92,2.34,6.94,2.34,2.16,0,4.02-.54,5.58-1.62,1.56-1.08,2.51-2.22,2.87-3.43h9.43c-1.51,4.68-3.82,8.02-6.94,10.03-3.12,2.01-6.89,3.02-11.32,3.02-3.07,0-5.84-.49-8.3-1.47-2.47-.98-4.55-2.38-6.26-4.19-1.71-1.81-3.03-3.97-3.96-6.49-.93-2.51-1.4-5.28-1.4-8.3s.48-5.63,1.43-8.15c.95-2.51,2.31-4.69,4.07-6.53,1.76-1.83,3.86-3.28,6.3-4.34,2.44-1.06,5.14-1.59,8.11-1.59,3.32,0,6.21.64,8.68,1.92,2.46,1.28,4.49,3.01,6.07,5.17,1.58,2.16,2.73,4.63,3.43,7.39.7,2.77.95,5.66.75,8.68h-28.14c.15,3.47,1.03,5.99,2.64,7.54Zm12.11-20.52c-1.28-1.41-3.23-2.11-5.85-2.11-1.71,0-3.13.29-4.26.87-1.13.58-2.04,1.3-2.72,2.15-.68.86-1.16,1.76-1.43,2.72-.28.96-.44,1.81-.49,2.57h17.43c-.5-2.72-1.4-4.78-2.68-6.19Z"/><path class="cls-1" d="m375.16,47.05c.95-2.54,2.31-4.7,4.07-6.49,1.76-1.79,3.87-3.17,6.34-4.15,2.46-.98,5.23-1.47,8.3-1.47s5.85.49,8.34,1.47c2.49.98,4.61,2.36,6.37,4.15,1.76,1.79,3.12,3.95,4.07,6.49.95,2.54,1.43,5.37,1.43,8.49s-.48,5.94-1.43,8.45c-.96,2.51-2.31,4.66-4.07,6.45-1.76,1.79-3.89,3.16-6.37,4.11-2.49.96-5.27,1.43-8.34,1.43s-5.84-.48-8.3-1.43c-2.47-.95-4.58-2.33-6.34-4.11-1.76-1.79-3.12-3.94-4.07-6.45-.96-2.51-1.43-5.33-1.43-8.45s.48-5.95,1.43-8.49Zm9.73,13.05c.3,1.48.82,2.82,1.55,4,.73,1.18,1.7,2.13,2.9,2.83,1.21.7,2.72,1.06,4.53,1.06s3.33-.35,4.56-1.06c1.23-.7,2.21-1.65,2.94-2.83.73-1.18,1.24-2.51,1.55-4,.3-1.48.45-3,.45-4.57s-.15-3.09-.45-4.6c-.3-1.51-.82-2.84-1.55-4-.73-1.16-1.71-2.1-2.94-2.83-1.23-.73-2.75-1.09-4.56-1.09s-3.32.37-4.53,1.09c-1.21.73-2.18,1.67-2.9,2.83-.73,1.16-1.25,2.49-1.55,4-.3,1.51-.45,3.04-.45,4.6s.15,3.08.45,4.57Z"/><path class="cls-1" d="m432.04,20v55h-10.71V20h10.71Z"/><path class="cls-1" d="m468.18,70.02c-1.26,2.11-2.9,3.63-4.94,4.56-2.04.93-4.34,1.4-6.9,1.4-2.92,0-5.48-.57-7.69-1.7-2.21-1.13-4.04-2.66-5.47-4.6-1.43-1.94-2.52-4.16-3.24-6.68-.73-2.51-1.09-5.13-1.09-7.85s.36-5.14,1.09-7.58c.73-2.44,1.81-4.59,3.24-6.45,1.43-1.86,3.23-3.36,5.39-4.49,2.16-1.13,4.68-1.7,7.54-1.7,2.31,0,4.51.49,6.6,1.47,2.09.98,3.73,2.43,4.94,4.34h.15v-20.75h10.71v55h-10.18v-4.98h-.15Zm-.45-19.28c-.3-1.48-.82-2.79-1.55-3.92-.73-1.13-1.67-2.05-2.83-2.75-1.16-.7-2.62-1.06-4.38-1.06s-3.24.35-4.45,1.06-2.18,1.63-2.9,2.79c-.73,1.16-1.26,2.48-1.58,3.96-.33,1.48-.49,3.03-.49,4.64,0,1.51.18,3.02.53,4.53.35,1.51.92,2.86,1.7,4.04.78,1.18,1.76,2.14,2.94,2.87,1.18.73,2.6,1.09,4.26,1.09,1.76,0,3.23-.35,4.41-1.06,1.18-.7,2.12-1.65,2.83-2.83.7-1.18,1.21-2.53,1.51-4.04.3-1.51.45-3.07.45-4.68s-.15-3.16-.45-4.64Z"/></g></g></g></svg> \ No newline at end of file diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg new file mode 100644 index 0000000000..12cf1da644 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 478.51 100"><g id="Layer_1-2"><g><path d="m52,25V0h-27v25h-15.9L0,50h25v50h28.35c5.25,0,9.95-3.29,11.75-8.22l5.46-15.01s.64-1.76,2.52-1.76h39.62l9.1-25h-32.75c-5.25,0-9.95,3.29-11.75,8.22l-5.46,15.01s-.64,1.76-2.52,1.76h-17.33v-25h19.55c5.25,0,9.95-3.29,11.75-8.22l5.46-15.01s.64-1.76,2.52-1.76h44.62L145,0h-37.75c-5.25,0-9.95,3.29-11.75,8.22l-5.46,15.01s-.64,1.76-2.52,1.76h-35.53Z"/><g><path d="m214.81,66.3l-2.12,5.83c-.63,1.72-2.26,2.86-4.09,2.86h-9.26c-2.4,0-4.34-1.94-4.34-4.34v-27.5h-6.49v-7.17h6.49v-11h10.71v11h7.85v7.17h-7.85v23.14h9.1Z"/><path d="m370.25,28.7h-7.39v7.3h7.39v7.17h-7.39v31.84h-10.71v-31.84h-6.49v-7.17h6.49v-11.66c0-2.4,1.94-4.34,4.34-4.34h16.93l-3.16,8.7Z"/><path d="m218.99,30v-10h10.71v10h-10.71Zm10.71,6v39h-10.71v-39h10.71Z"/><path d="m248.56,36v5.28h.15c1.41-2.01,3.1-3.57,5.09-4.68,1.99-1.11,4.26-1.66,6.83-1.66s4.72.48,6.75,1.43c2.04.96,3.58,2.64,4.64,5.05,1.16-1.71,2.73-3.22,4.72-4.53,1.99-1.31,4.34-1.96,7.05-1.96,2.06,0,3.97.25,5.73.76,1.76.5,3.27,1.31,4.53,2.41,1.26,1.11,2.24,2.55,2.94,4.34.7,1.79,1.06,3.94,1.06,6.45v26.1h-10.71v-22.1c0-1.31-.05-2.54-.15-3.7-.1-1.16-.38-2.16-.83-3.02-.45-.85-1.12-1.53-2-2.04-.88-.5-2.08-.75-3.58-.75s-2.73.29-3.66.87c-.93.58-1.66,1.33-2.19,2.26-.53.93-.88,1.99-1.06,3.17-.18,1.18-.26,2.38-.26,3.58v21.73h-10.71v-21.88c0-1.16-.03-2.3-.08-3.43-.05-1.13-.26-2.17-.64-3.13-.38-.95-1.01-1.72-1.89-2.3-.88-.58-2.18-.87-3.89-.87-.5,0-1.17.11-2,.34-.83.23-1.63.65-2.41,1.28-.78.63-1.45,1.53-2,2.71-.55,1.18-.83,2.73-.83,4.64v22.63h-10.71v-39h10.11Z"/><path d="m317.97,65.64c1.61,1.56,3.92,2.34,6.94,2.34,2.16,0,4.02-.54,5.58-1.62,1.56-1.08,2.51-2.22,2.87-3.43h9.43c-1.51,4.68-3.82,8.02-6.94,10.03-3.12,2.01-6.89,3.02-11.32,3.02-3.07,0-5.84-.49-8.3-1.47-2.47-.98-4.55-2.38-6.26-4.19-1.71-1.81-3.03-3.97-3.96-6.49-.93-2.51-1.4-5.28-1.4-8.3s.48-5.63,1.43-8.15c.95-2.51,2.31-4.69,4.07-6.53,1.76-1.83,3.86-3.28,6.3-4.34,2.44-1.06,5.14-1.59,8.11-1.59,3.32,0,6.21.64,8.68,1.92,2.46,1.28,4.49,3.01,6.07,5.17,1.58,2.16,2.73,4.63,3.43,7.39.7,2.77.95,5.66.75,8.68h-28.14c.15,3.47,1.03,5.99,2.64,7.54Zm12.11-20.52c-1.28-1.41-3.23-2.11-5.85-2.11-1.71,0-3.13.29-4.26.87-1.13.58-2.04,1.3-2.72,2.15-.68.86-1.16,1.76-1.43,2.72-.28.96-.44,1.81-.49,2.57h17.43c-.5-2.72-1.4-4.78-2.68-6.19Z"/><path d="m375.16,47.05c.95-2.54,2.31-4.7,4.07-6.49,1.76-1.79,3.87-3.17,6.34-4.15,2.46-.98,5.23-1.47,8.3-1.47s5.85.49,8.34,1.47c2.49.98,4.61,2.36,6.37,4.15,1.76,1.79,3.12,3.95,4.07,6.49.95,2.54,1.43,5.37,1.43,8.49s-.48,5.94-1.43,8.45c-.96,2.51-2.31,4.66-4.07,6.45-1.76,1.79-3.89,3.16-6.37,4.11-2.49.96-5.27,1.43-8.34,1.43s-5.84-.48-8.3-1.43c-2.47-.95-4.58-2.33-6.34-4.11-1.76-1.79-3.12-3.94-4.07-6.45-.96-2.51-1.43-5.33-1.43-8.45s.48-5.95,1.43-8.49Zm9.73,13.05c.3,1.48.82,2.82,1.55,4,.73,1.18,1.7,2.13,2.9,2.83,1.21.7,2.72,1.06,4.53,1.06s3.33-.35,4.56-1.06c1.23-.7,2.21-1.65,2.94-2.83.73-1.18,1.24-2.51,1.55-4,.3-1.48.45-3,.45-4.57s-.15-3.09-.45-4.6c-.3-1.51-.82-2.84-1.55-4-.73-1.16-1.71-2.1-2.94-2.83-1.23-.73-2.75-1.09-4.56-1.09s-3.32.37-4.53,1.09c-1.21.73-2.18,1.67-2.9,2.83-.73,1.16-1.25,2.49-1.55,4-.3,1.51-.45,3.04-.45,4.6s.15,3.08.45,4.57Z"/><path d="m432.04,20v55h-10.71V20h10.71Z"/><path d="m468.18,70.02c-1.26,2.11-2.9,3.63-4.94,4.56-2.04.93-4.34,1.4-6.9,1.4-2.92,0-5.48-.57-7.69-1.7-2.21-1.13-4.04-2.66-5.47-4.6-1.43-1.94-2.52-4.16-3.24-6.68-.73-2.51-1.09-5.13-1.09-7.85s.36-5.14,1.09-7.58c.73-2.44,1.81-4.59,3.24-6.45,1.43-1.86,3.23-3.36,5.39-4.49,2.16-1.13,4.68-1.7,7.54-1.7,2.31,0,4.51.49,6.6,1.47,2.09.98,3.73,2.43,4.94,4.34h.15v-20.75h10.71v55h-10.18v-4.98h-.15Zm-.45-19.28c-.3-1.48-.82-2.79-1.55-3.92-.73-1.13-1.67-2.05-2.83-2.75-1.16-.7-2.62-1.06-4.38-1.06s-3.24.35-4.45,1.06-2.18,1.63-2.9,2.79c-.73,1.16-1.26,2.48-1.58,3.96-.33,1.48-.49,3.03-.49,4.64,0,1.51.18,3.02.53,4.53.35,1.51.92,2.86,1.7,4.04.78,1.18,1.76,2.14,2.94,2.87,1.18.73,2.6,1.09,4.26,1.09,1.76,0,3.23-.35,4.41-1.06,1.18-.7,2.12-1.65,2.83-2.83.7-1.18,1.21-2.53,1.51-4.04.3-1.51.45-3.07.45-4.68s-.15-3.16-.45-4.64Z"/></g></g></g></svg> \ No newline at end of file diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg new file mode 100644 index 0000000000..7c871643b2 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 290 300.98"><g id="Layer_1-2"><g><path d="m104,50V0h-54v50h-31.8L0,100h50v100h56.7c10.51,0,19.9-6.57,23.49-16.45l10.93-30.03s1.29-3.52,5.03-3.52h79.25l18.2-50h-65.49c-10.51,0-19.9,6.57-23.49,16.45l-10.93,30.03s-1.28,3.52-5.03,3.52h-34.65v-50h39.1c10.51,0,19.9-6.57,23.49-16.45l10.93-30.03s1.29-3.52,5.03-3.52h89.25L290,0h-75.49c-10.51,0-19.9,6.57-23.49,16.45l-10.93,30.03s-1.28,3.52-5.03,3.52h-71.05Z"/><g><path d="m26.3,291.3l-2.12,5.83c-.63,1.72-2.26,2.86-4.09,2.86h-9.26c-2.4,0-4.34-1.94-4.34-4.34v-27.5H0v-7.17h6.49v-11h10.71v11h7.85v7.17h-7.85v23.14h9.1Z"/><path d="m181.74,253.7h-7.39v7.3h7.39v7.17h-7.39v31.84h-10.71v-31.84h-6.49v-7.17h6.49v-11.66c0-2.4,1.94-4.34,4.34-4.34h16.93l-3.16,8.7Z"/><path d="m30.48,255v-10h10.71v10h-10.71Zm10.71,6v39h-10.71v-39h10.71Z"/><path d="m60.05,261v5.28h.15c1.41-2.01,3.1-3.57,5.09-4.68,1.99-1.11,4.26-1.66,6.83-1.66s4.72.48,6.75,1.43c2.04.96,3.58,2.64,4.64,5.05,1.16-1.71,2.73-3.22,4.72-4.53,1.99-1.31,4.34-1.96,7.05-1.96,2.06,0,3.97.25,5.73.76,1.76.5,3.27,1.31,4.53,2.41,1.26,1.11,2.24,2.55,2.94,4.34.7,1.79,1.06,3.94,1.06,6.45v26.1h-10.71v-22.1c0-1.31-.05-2.54-.15-3.7-.1-1.16-.38-2.16-.83-3.02-.45-.85-1.12-1.53-2-2.04-.88-.5-2.08-.75-3.58-.75s-2.73.29-3.66.87c-.93.58-1.66,1.33-2.19,2.26-.53.93-.88,1.99-1.06,3.17-.18,1.18-.26,2.38-.26,3.58v21.73h-10.71v-21.88c0-1.16-.03-2.3-.08-3.43-.05-1.13-.26-2.17-.64-3.13-.38-.95-1.01-1.72-1.89-2.3-.88-.58-2.18-.87-3.89-.87-.5,0-1.17.11-2,.34-.83.23-1.63.65-2.41,1.28-.78.63-1.45,1.53-2,2.71-.55,1.18-.83,2.73-.83,4.64v22.63h-10.71v-39h10.11Z"/><path d="m129.46,290.64c1.61,1.56,3.92,2.34,6.94,2.34,2.16,0,4.02-.54,5.58-1.62,1.56-1.08,2.51-2.22,2.87-3.43h9.43c-1.51,4.68-3.82,8.02-6.94,10.03-3.12,2.01-6.89,3.02-11.32,3.02-3.07,0-5.84-.49-8.3-1.47-2.47-.98-4.55-2.38-6.26-4.19-1.71-1.81-3.03-3.97-3.96-6.49-.93-2.51-1.4-5.28-1.4-8.3s.48-5.63,1.43-8.15c.95-2.51,2.31-4.69,4.07-6.53,1.76-1.83,3.86-3.28,6.3-4.34,2.44-1.06,5.14-1.59,8.11-1.59,3.32,0,6.21.64,8.68,1.92,2.46,1.28,4.49,3.01,6.07,5.17,1.58,2.16,2.73,4.63,3.43,7.39.7,2.77.95,5.66.75,8.68h-28.14c.15,3.47,1.03,5.99,2.64,7.54Zm12.11-20.52c-1.28-1.41-3.23-2.11-5.85-2.11-1.71,0-3.13.29-4.26.87-1.13.58-2.04,1.3-2.72,2.15-.68.86-1.16,1.76-1.43,2.72-.28.96-.44,1.81-.49,2.57h17.43c-.5-2.72-1.4-4.78-2.68-6.19Z"/><path d="m186.64,272.05c.95-2.54,2.31-4.7,4.07-6.49,1.76-1.79,3.87-3.17,6.34-4.15,2.46-.98,5.23-1.47,8.3-1.47s5.85.49,8.34,1.47c2.49.98,4.61,2.36,6.37,4.15,1.76,1.79,3.12,3.95,4.07,6.49.95,2.54,1.43,5.37,1.43,8.49s-.48,5.94-1.43,8.45c-.96,2.51-2.31,4.66-4.07,6.45-1.76,1.79-3.89,3.16-6.37,4.11-2.49.96-5.27,1.43-8.34,1.43s-5.84-.48-8.3-1.43c-2.47-.95-4.58-2.33-6.34-4.11-1.76-1.79-3.12-3.94-4.07-6.45-.96-2.51-1.43-5.33-1.43-8.45s.48-5.95,1.43-8.49Zm9.73,13.05c.3,1.48.82,2.82,1.55,4,.73,1.18,1.7,2.13,2.9,2.83,1.21.7,2.72,1.06,4.53,1.06s3.33-.35,4.56-1.06c1.23-.7,2.21-1.65,2.94-2.83.73-1.18,1.24-2.51,1.55-4,.3-1.48.45-3,.45-4.57s-.15-3.09-.45-4.6c-.3-1.51-.82-2.84-1.55-4-.73-1.16-1.71-2.1-2.94-2.83-1.23-.73-2.75-1.09-4.56-1.09s-3.32.37-4.53,1.09c-1.21.73-2.18,1.67-2.9,2.83-.73,1.16-1.25,2.49-1.55,4-.3,1.51-.45,3.04-.45,4.6s.15,3.08.45,4.57Z"/><path d="m243.53,245v55h-10.71v-55h10.71Z"/><path d="m279.66,295.02c-1.26,2.11-2.9,3.63-4.94,4.56-2.04.93-4.34,1.4-6.9,1.4-2.92,0-5.48-.57-7.69-1.7-2.21-1.13-4.04-2.66-5.47-4.6-1.43-1.94-2.52-4.16-3.24-6.68-.73-2.51-1.09-5.13-1.09-7.85s.36-5.14,1.09-7.58c.73-2.44,1.81-4.59,3.24-6.45,1.43-1.86,3.23-3.36,5.39-4.49,2.16-1.13,4.68-1.7,7.54-1.7,2.31,0,4.51.49,6.6,1.47,2.09.98,3.73,2.43,4.94,4.34h.15v-20.75h10.71v55h-10.18v-4.98h-.15Zm-.45-19.28c-.3-1.48-.82-2.79-1.55-3.92-.73-1.13-1.67-2.05-2.83-2.75-1.16-.7-2.62-1.06-4.38-1.06s-3.24.35-4.45,1.06-2.18,1.63-2.9,2.79c-.73,1.16-1.26,2.48-1.58,3.96-.33,1.48-.49,3.03-.49,4.64,0,1.51.18,3.02.53,4.53.35,1.51.92,2.86,1.7,4.04.78,1.18,1.76,2.14,2.94,2.87,1.18.73,2.6,1.09,4.26,1.09,1.76,0,3.23-.35,4.41-1.06,1.18-.7,2.12-1.65,2.83-2.83.7-1.18,1.21-2.53,1.51-4.04.3-1.51.45-3.07.45-4.68s-.15-3.16-.45-4.64Z"/></g></g></g></svg> \ No newline at end of file diff --git a/python/sports-league-scheduling/static/webjars/timefold/js/timefold-webui.js b/python/sports-league-scheduling/static/webjars/timefold/js/timefold-webui.js new file mode 100644 index 0000000000..dc8853c3f4 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/js/timefold-webui.js @@ -0,0 +1,142 @@ +function replaceTimefoldAutoHeaderFooter() { + const timefoldHeader = $("header#timefold-auto-header"); + if (timefoldHeader != null) { + timefoldHeader.addClass("bg-black") + timefoldHeader.append( + $(`<div class="container-fluid"> + <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3"> + <a class="navbar-brand" href="https://timefold.ai"> + <img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200"> + </a> + </nav> + </div>`)); + } + const timefoldFooter = $("footer#timefold-auto-footer"); + if (timefoldFooter != null) { + timefoldFooter.append( + $(`<footer class="bg-black text-white-50"> + <div class="container"> + <div class="hstack gap-3 p-4"> + <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div> + <div class="vr"></div> + <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div> + <div class="vr"></div> + <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div> + <div class="vr"></div> + <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div> + </div> + </div> + <div id="applicationInfo" class="container text-center"></div> + </footer>`)); + + applicationInfo(); + } + +} + +function showSimpleError(title) { + const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`) + .append($(`<div class="toast-header bg-danger"> + <strong class="me-auto text-dark">Error</strong> + <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> + </div>`)) + .append($(`<div class="toast-body"/>`) + .append($(`<p/>`).text(title)) + ); + $("#notificationPanel").append(notification); + notification.toast({delay: 30000}); + notification.toast('show'); +} + +function showError(title, xhr) { + var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message; + var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code; + var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id; + var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details; + + if (xhr.responseJSON && !serverErrorMessage) { + serverErrorMessage = JSON.stringify(xhr.responseJSON); + serverErrorCode = xhr.statusText + '(' + xhr.status + ')'; + serverErrorId = `----`; + } + + console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails); + const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`) + .append($(`<div class="toast-header bg-danger"> + <strong class="me-auto text-dark">Error</strong> + <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> + </div>`)) + .append($(`<div class="toast-body"/>`) + .append($(`<p/>`).text(title)) + .append($(`<pre/>`) + .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId)) + ) + ); + $("#notificationPanel").append(notification); + notification.toast({delay: 30000}); + notification.toast('show'); +} + +// **************************************************************************** +// Application info +// **************************************************************************** + +function applicationInfo() { + $.getJSON("info", function (info) { + $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>"); + }).fail(function (xhr, ajaxOptions, thrownError) { + console.warn("Unable to collect application information"); + }); +} + +// **************************************************************************** +// TangoColorFactory +// **************************************************************************** + +const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8]; +const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B]; + +var colorMap = new Map; +var nextColorCount = 0; + +function pickColor(object) { + let color = colorMap[object]; + if (color !== undefined) { + return color; + } + color = nextColor(); + colorMap[object] = color; + return color; +} + +function nextColor() { + let color; + let colorIndex = nextColorCount % SEQUENCE_1.length; + let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); + if (shadeIndex === 0) { + color = SEQUENCE_1[colorIndex]; + } else if (shadeIndex === 1) { + color = SEQUENCE_2[colorIndex]; + } else { + shadeIndex -= 3; + let floorColor = SEQUENCE_2[colorIndex]; + let ceilColor = SEQUENCE_1[colorIndex]; + let base = Math.floor((shadeIndex / 2) + 1); + let divisor = 2; + while (base >= divisor) { + divisor *= 2; + } + base = (base * 2) - divisor + 1; + let shadePercentage = base / divisor; + color = buildPercentageColor(floorColor, ceilColor, shadePercentage); + } + nextColorCount++; + return "#" + color.toString(16); +} + +function buildPercentageColor(floorColor, ceilColor, shadePercentage) { + let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000; + let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00; + let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF; + return red | green | blue; +} diff --git a/python/sports-league-scheduling/tests/test_constraints.py b/python/sports-league-scheduling/tests/test_constraints.py new file mode 100644 index 0000000000..15e8eacea9 --- /dev/null +++ b/python/sports-league-scheduling/tests/test_constraints.py @@ -0,0 +1,134 @@ +from timefold.solver.test import ConstraintVerifier + +from sports_league_scheduling.domain import * +from sports_league_scheduling.constraints import * + +constraint_verifier = ConstraintVerifier.build(define_constraints, LeagueSchedule, Match) + + +def test_matches_same_day(): + # Two matches for the home team + home_team = Team(id="1", name="TeamA") + rival_team = Team(id="2", name="TeamB") + match1 = Match(id="1", home_team=home_team, away_team=rival_team, round=Round(index=0)) + match2 = Match(id="2", home_team=home_team, away_team=rival_team, round=Round(index=0)) + match3 = Match(id="3", home_team=home_team, away_team=rival_team) + + constraint_verifier.verify_that(matches_on_same_day).given(match1, match2, match3).penalizes_by(1) + + # Two matches, one for home and another for away match + other_team = Team(id="3", name="TeamC") + match1 = Match(id="1", home_team=home_team, away_team=rival_team, round=Round(index=0)) + match2 = Match(id="2", home_team=rival_team, away_team=other_team, round=Round(index=0)) + + constraint_verifier.verify_that(matches_on_same_day).given(match1, match2, match3).penalizes_by(1) + + +def test_multiple_consecutive_home_matches(): + home_team = Team(id="1", name="TeamA") + rival_team = Team(id="2", name="TeamB") + matches = [ + Match(id="1", home_team=home_team, away_team=rival_team, round=Round(index=0)), + Match(id="2", home_team=home_team, away_team=rival_team, round=Round(index=1)), + Match(id="3", home_team=home_team, away_team=rival_team, round=Round(index=2)), + Match(id="4", home_team=home_team, away_team=rival_team, round=Round(index=3)), + Match(id="5", home_team=Team(id="3", name="TeamC"), away_team=home_team) + ] + # four consecutive home matches for homeTeam + constraint_verifier.verify_that(multiple_consecutive_home_matches).given(*matches, home_team, rival_team).penalizes_by(4) + + +def test_multiple_consecutive_away_matches(): + home_team = Team(id="1", name="TeamA") + rival_team = Team(id="2", name="TeamB") + matches = [ + Match(id="1", home_team=home_team, away_team=rival_team, round=Round(index=0)), + Match(id="2", home_team=home_team, away_team=rival_team, round=Round(index=1)), + Match(id="3", home_team=home_team, away_team=rival_team, round=Round(index=2)), + Match(id="4", home_team=home_team, away_team=rival_team, round=Round(index=3)), + Match(id="5", home_team=Team(id="3", name="TeamC"), away_team=home_team) + ] + # four consecutive away matches for homeTeam + constraint_verifier.verify_that(multiple_consecutive_away_matches).given(*matches, home_team, rival_team).penalizes_by(4) + + +def test_repeat_match_on_next_day(): + home_team = Team(id="1", name="TeamA") + rival_team = Team(id="2", name="TeamB") + matches = [ + Match(id="1", home_team=home_team, away_team=rival_team, round=Round(index=0)), + Match(id="2", home_team=rival_team, away_team=home_team, round=Round(index=1)), + Match(id="3", home_team=home_team, away_team=rival_team, round=Round(index=4)), + Match(id="4", home_team=rival_team, away_team=home_team, round=Round(index=6)) + ] + # one match repeating on the next day + constraint_verifier.verify_that(repeat_match_on_the_next_day).given(*matches).penalizes_by(1) + + +def test_start_to_away_hop(): + home_team = Team(id="1", name="TeamA", distance_to_team={"2": 5}) + second_team = Team(id="2", name="TeamB", distance_to_team={"1": 5}) + third_team = Team(id="3", name="TeamC") + matches = [ + Match(id="1", home_team=home_team, away_team=second_team, round=Round(index=0)), + Match(id="2", home_team=home_team, away_team=third_team, round=Round(index=1)) + ] + # match with the second team + constraint_verifier.verify_that(start_to_away_hop).given(*matches).penalizes_by(5) + + +def test_home_to_away_hop(): + home_team = Team(id="1", name="TeamA", distance_to_team={"3": 7}) + second_team = Team(id="2", name="TeamB") + third_team = Team(id="3", name="TeamC", distance_to_team={"1": 7}) + matches = [ + Match(id="1", home_team=home_team, away_team=second_team, round=Round(index=0)), + Match(id="2", home_team=third_team, away_team=home_team, round=Round(index=1)) + ] + # match with the home team + constraint_verifier.verify_that(home_to_away_hop).given(*matches).penalizes_by(7) + + +def test_away_to_away_hop(): + second_team = Team(id="2", name="TeamB", distance_to_team={"3": 2}) + third_team = Team(id="3", name="TeamC", distance_to_team={"2": 2}) + matches = [ + Match(id="1", home_team=second_team, away_team=Team(id="1", name="TeamA"), round=Round(index=0)), + Match(id="2", home_team=third_team, away_team=Team(id="1", name="TeamA"), round=Round(index=1)) + ] + # match with the home team + constraint_verifier.verify_that(away_to_away_hop).given(*matches).penalizes_by(2) + + +def test_away_to_home_hop(): + home_team = Team(id="1",name="TeamA", distance_to_team={"2": 20}) + second_team = Team(id="2", name="TeamB", distance_to_team={"1": 20}) + matches = [ + Match(id="1", home_team=second_team, away_team=home_team, round=Round(index=0)), + Match(id="2", home_team=home_team, away_team=Team(id="3", name="TeamC"), round=Round(index=1)) + ] + # match with the home team + constraint_verifier.verify_that(away_to_home_hop).given(*matches).penalizes_by(20) + + +def test_away_to_end_hop(): + home_team = Team(id="1", name="TeamA", distance_to_team={"3": 15}) + third_team = Team(id="3", name="TeamC", distance_to_team={"1": 15}) + matches = [ + Match(id="1", home_team=home_team, away_team=Team(id="2", name="TeamB"), round=Round(index=0)), + Match(id="2", home_team=third_team, away_team=home_team, round=Round(index=1)) + ] + # match with the home team + constraint_verifier.verify_that(away_to_end_hop).given(*matches).penalizes_by(15) + + +def test_classic_matches(): + home_team = Team(id="1", name="TeamA") + rival_team = Team(id="2", name="TeamB") + matches = [ + Match(id="1", home_team=home_team, away_team=rival_team, round=Round(index=0),classic_match=True), + Match(id="2", home_team=rival_team, away_team=home_team, round=Round(index=1)), + Match(id="3", home_team=home_team, away_team=rival_team, round=Round(index=4), classic_match=True) + ] + # two classic matches + constraint_verifier.verify_that(classic_matches).given(*matches).penalizes_by(2) \ No newline at end of file diff --git a/python/sports-league-scheduling/tests/test_feasible.py b/python/sports-league-scheduling/tests/test_feasible.py new file mode 100644 index 0000000000..9b4a1e283a --- /dev/null +++ b/python/sports-league-scheduling/tests/test_feasible.py @@ -0,0 +1,68 @@ +from sports_league_scheduling.rest_api import app +from sports_league_scheduling.domain import * + +from fastapi.testclient import TestClient +from time import sleep +from pytest import fail + +client = TestClient(app) + + +def test_feasible(): + # Get Demo Data + demo_data_response = client.get("/demo-data") + assert demo_data_response.status_code == 200 + demo_data = demo_data_response.json() + assert demo_data is not None + + # Post Schedule + job_id_response = client.post("/schedules", json=demo_data_response.json()) + assert job_id_response.status_code == 200 + job_id = job_id_response.text[1:-1] + + # Test Feasibility + ATTEMPTS: int = 1_000 + for _ in range(ATTEMPTS): + sleep(0.1) + league_schedule_response = client.get(f"/schedules/{job_id}") + assert league_schedule_response.status_code == 200 + league_schedule_json = league_schedule_response.json() + league_schedule = LeagueSchedule.model_validate(league_schedule_json, + context={ + 'rounds': { + match_round['index']: Round.model_validate(match_round) for + match_round in league_schedule_json.get('rounds', []) + }, + 'teams': { + team['id']: Team.model_validate(team) for + team in league_schedule_json.get('teams', []) + }, + }) + if league_schedule.score is not None and league_schedule.score.is_feasible: + stop_solving_response = client.delete(f"/schedules/{job_id}") + assert stop_solving_response.status_code == 200 + return + + client.delete(f"/schedules/{job_id}") + fail('solution is not feasible') + + # Analyze the Timetable + analyze_response = client.put("/schedules/analyze", json=demo_data) + assert analyze_response.status_code == 200 + analyze_data = analyze_response.json() + assert "constraints" in analyze_data + + # Check Solver Status + status_response = client.get(f"/schedules/{job_id}/status") + assert status_response.status_code == 200 + status_data = status_response.json() + assert "solver_status" in status_data + assert status_data["solver_status"] == "NOT_SOLVING" + + # Stop Solving + stop_solving_response = client.delete(f"/schedules/{job_id}") + assert stop_solving_response.status_code == 200 + + # Verify Cleanup + get_after_delete_response = client.get(f"/schedules/{job_id}") + assert get_after_delete_response.status_code == 404