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