diff --git a/src/containers/LeagueStage/Matrix.tsx b/src/containers/LeagueStage/Matrix.tsx
index 83b5d6d3..9b71fd05 100644
--- a/src/containers/LeagueStage/Matrix.tsx
+++ b/src/containers/LeagueStage/Matrix.tsx
@@ -146,7 +146,7 @@ interface Props {
allTeams: readonly Team[];
numMatchdays: number;
pairings: (readonly [Team, Team])[];
- schedule: readonly (readonly (readonly [Team, Team])[])[];
+ schedule: readonly (readonly (readonly (readonly [Team, Team])[])[])[];
potSize: number;
noCellAnimation?: boolean;
}
@@ -172,8 +172,10 @@ function Matrix({
const scheduleMap = useMemo(() => {
const o: Record<`${string}:${string}`, number> = {};
for (const [mdIndex, md] of schedule.entries()) {
- for (const m of md) {
- o[`${m[0].id}:${m[1].id}`] = mdIndex;
+ for (const day of md) {
+ for (const m of day) {
+ o[`${m[0].id}:${m[1].id}`] = mdIndex;
+ }
}
}
return o;
diff --git a/src/containers/LeagueStage/Schedule.tsx b/src/containers/LeagueStage/Schedule.tsx
index d8c535a5..77e138ba 100644
--- a/src/containers/LeagueStage/Schedule.tsx
+++ b/src/containers/LeagueStage/Schedule.tsx
@@ -3,6 +3,7 @@ import styled from 'styled-components';
import ContentWithFlag from '#ui/table/ContentWithFlag';
import { type Country } from '#model/types';
+import type Tournament from '#model/Tournament';
const Root = styled.div`
width: 100%;
@@ -13,6 +14,7 @@ const CalendarContainer = styled.div`
display: grid;
gap: 16px;
grid-template-columns: repeat(4, 1fr);
+ align-items: self-start;
width: fit-content;
@container (max-width: 1000px) {
@@ -25,7 +27,7 @@ const CalendarContainer = styled.div`
`;
const MatchdayRoot = styled.div`
- border: 1px double rgb(128 128 128);
+ border: 1px solid rgb(192 192 192);
font-size: 12px;
`;
@@ -34,6 +36,18 @@ const MatchdayHeader = styled.div`
justify-content: center;
align-items: center;
height: 20px;
+ background-color: black;
+ color: white;
+ font-weight: 600;
+`;
+
+const DayHeader = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 20px;
+ border-top: 1px solid rgb(192 192 192);
+ background-color: rgb(240 240 240);
`;
const MatchPair = styled.div`
@@ -64,10 +78,11 @@ interface Team {
}
interface Props {
- schedule: readonly (readonly (readonly [Team, Team])[])[];
+ tournament: Tournament;
+ schedule: readonly (readonly (readonly (readonly [Team, Team])[])[])[];
}
-function Schedule({ schedule }: Props) {
+function Schedule({ tournament, schedule }: Props) {
useLayoutEffect(() => {
if (schedule.some(md => md.length > 0)) {
const elements = document.getElementsByClassName(
@@ -89,20 +104,35 @@ function Schedule({ schedule }: Props) {
{schedule.map((md, i) => (
MATCHDAY {i + 1}
- {md.map(m => (
-
-
-
- {m[0].name}
-
-
- -
-
-
- {m[1].name}
-
-
-
+ {md.map((day, dayIndex) => (
+ <>
+
+ {tournament === 'cl'
+ ? dayIndex === 2
+ ? 'Thursday'
+ : dayIndex === 1 || md.length === 1
+ ? 'Wednesday'
+ : 'Tuesday'
+ : dayIndex === 1 || md.length === 1
+ ? 'Night'
+ : 'Evening'}
+
+ {day.map(m => (
+
+
+
+ {m[0].name}
+
+
+ -
+
+
+ {m[1].name}
+
+
+
+ ))}
+ >
))}
))}
diff --git a/src/containers/LeagueStage/index.tsx b/src/containers/LeagueStage/index.tsx
index 5a72e707..2b072986 100644
--- a/src/containers/LeagueStage/index.tsx
+++ b/src/containers/LeagueStage/index.tsx
@@ -1,8 +1,8 @@
import { memo, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
-import { shuffle } from 'lodash';
import usePopup from '#store/usePopup';
+import type Tournament from '#model/Tournament';
import type Team from '#model/team/GsTeam';
import generatePairings from '#engine/dfs/ls/generatePairings/index';
import generateSchedule from '#engine/dfs/ls/generateSchedule/index';
@@ -31,11 +31,18 @@ const MatrixWrapper = styled.div`
`;
interface Props {
+ tournament: Tournament;
season: number;
pots: readonly (readonly Team[])[];
+ tvPairings: readonly (readonly [Team, Team])[];
}
-function LeagueStage({ season, pots: initialPots }: Props) {
+function LeagueStage({
+ tournament,
+ season,
+ pots: initialPots,
+ tvPairings,
+}: Props) {
const numMatchdays = initialPots.length * 2;
const numMatches = useMemo(() => {
@@ -48,7 +55,7 @@ function LeagueStage({ season, pots: initialPots }: Props) {
const [isMatchdayMode, setIsMatchdayMode] = useState(false);
const [pairings, setPairings] = useState<(readonly [Team, Team])[]>([]);
- const [schedule, setSchedule] = useState<(readonly [Team, Team])[][]>(
+ const [schedule, setSchedule] = useState<(readonly [Team, Team])[][][]>(
Array.from(
{
length: numMatchdays,
@@ -64,7 +71,6 @@ function LeagueStage({ season, pots: initialPots }: Props) {
initialPots.map(pot =>
pot.map(team => ({
...team,
- id: `${team.country}|${team.name}`,
})),
),
[initialPots],
@@ -101,13 +107,14 @@ function LeagueStage({ season, pots: initialPots }: Props) {
if (isFixturesDone) {
const formSchedule = async () => {
const it = await generateSchedule({
+ tournament,
matchdaySize,
+ tvPairings,
allGames: pairings,
currentSchedule: schedule,
signal: abortSignal,
});
- const newSchedule = it.solutionSchedule.map(md => shuffle(md));
- setSchedule(newSchedule);
+ setSchedule(it.solutionSchedule);
setIsMatchdayMode(true);
};
@@ -137,7 +144,10 @@ function LeagueStage({ season, pots: initialPots }: Props) {
{isMatchdayMode ? (
-
+
) : (
{
const numGames = allGames.length;
@@ -205,9 +211,16 @@ export default ({
arr[matchdayIndex].push(m);
}
arr[solution.pickedMatchday].push(allGames[solution.matchIndex]);
+ const matchdays = splitMatchdaysIntoDays({
+ matchdays: arr,
+ tournament,
+ matchdaySize,
+ teams,
+ tvPairings,
+ });
return {
pickedMatchday,
- matchdays: arr,
+ matchdays,
};
}
}
diff --git a/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts b/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts
index 1a578d60..bc11c038 100644
--- a/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts
+++ b/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts
@@ -2,6 +2,7 @@ import { remove, sample, shuffle } from 'lodash';
import raceWorkers from '#utils/raceWorkers';
import { type UefaCountry } from '#model/types';
+import type Tournament from '#model/Tournament';
import { type Func } from './getFirstSuitableMatchday.worker';
import teamsSharingStadium from './teamsSharingStadium';
@@ -14,12 +15,16 @@ interface Team {
}
export default ({
+ tournament,
teams,
matchdaySize,
+ tvPairings,
allGames,
signal,
}: {
+ tournament: Tournament;
teams: readonly Team[];
+ tvPairings: readonly (readonly [number, number])[];
matchdaySize: number;
allGames: readonly (readonly [number, number])[];
signal?: AbortSignal;
@@ -79,7 +84,9 @@ export default ({
}
return {
+ tournament,
teams,
+ tvPairings,
matchdaySize,
allGames: orderedGames,
};
diff --git a/src/engine/dfs/ls/generateSchedule/index.ts b/src/engine/dfs/ls/generateSchedule/index.ts
index d6c27108..b68a3d9f 100644
--- a/src/engine/dfs/ls/generateSchedule/index.ts
+++ b/src/engine/dfs/ls/generateSchedule/index.ts
@@ -1,6 +1,7 @@
import { keyBy, uniq } from 'lodash';
import { type UefaCountry } from '#model/types';
+import type Tournament from '#model/Tournament';
import getFirstSuitableMatchday from './getFirstSuitableMatchday.wrapper';
@@ -11,13 +12,17 @@ interface Team {
}
export default async function generateSchedule({
+ tournament,
matchdaySize,
+ tvPairings,
allGames: allGamesWithIds,
signal,
}: {
+ tournament: Tournament;
matchdaySize: number;
+ tvPairings: readonly (readonly [T, T])[];
allGames: readonly (readonly [T, T])[];
- currentSchedule: readonly (readonly (readonly [T, T])[])[];
+ currentSchedule: readonly (readonly (readonly (readonly [T, T])[])[])[];
signal?: AbortSignal;
}) {
const allNonUniqueTeams = allGamesWithIds.flat();
@@ -40,21 +45,29 @@ export default async function generateSchedule({
// m => Math.max(...m),
// ]);
+ const tvPairingsNumbers = tvPairings.map(
+ p => [indexByTeamId.get(p[0].id)!, indexByTeamId.get(p[1].id)!] as const,
+ );
+
const result = await getFirstSuitableMatchday({
+ tournament,
teams: allTeams,
+ tvPairings: tvPairingsNumbers,
matchdaySize,
allGames: allGamesUnordered,
signal,
});
const solutionSchedule = result.matchdays.map(md =>
- md.map(([h, a]) => {
- const ht = teamById[allTeamIds[h]];
- const at = teamById[allTeamIds[a]];
- return allGamesWithIds.find(
- mi => mi[0].id === ht.id && mi[1].id === at.id,
- )!;
- }),
+ md.map(day =>
+ day.map(([h, a]) => {
+ const ht = teamById[allTeamIds[h]];
+ const at = teamById[allTeamIds[a]];
+ return allGamesWithIds.find(
+ mi => mi[0].id === ht.id && mi[1].id === at.id,
+ )!;
+ }),
+ ),
);
return {
diff --git a/src/engine/dfs/ls/generateSchedule/splitMatchdaysIntoDays.ts b/src/engine/dfs/ls/generateSchedule/splitMatchdaysIntoDays.ts
new file mode 100644
index 00000000..5704d63b
--- /dev/null
+++ b/src/engine/dfs/ls/generateSchedule/splitMatchdaysIntoDays.ts
@@ -0,0 +1,107 @@
+import { countBy, minBy, orderBy, pull, shuffle, sumBy } from 'lodash';
+
+import { type UefaCountry } from '#model/types';
+import type Tournament from '#model/Tournament';
+
+interface Team {
+ readonly name: string;
+ readonly country: UefaCountry;
+}
+
+export default ({
+ matchdays,
+ tournament,
+ matchdaySize,
+ teams,
+ tvPairings,
+}: {
+ matchdays: readonly (readonly [number, number])[][];
+ tournament: Tournament;
+ matchdaySize: number;
+ teams: readonly Team[];
+ tvPairings: readonly (readonly [number, number])[];
+}) => {
+ const numMatchdays = matchdays.length;
+
+ const allPairedTeams = new Set(tvPairings.flat());
+
+ const numTeamsByCountry = countBy(teams, team => team.country);
+ const countriesWithMultipleTeams = Object.keys(numTeamsByCountry).filter(
+ c => numTeamsByCountry[c] > 1,
+ ) as UefaCountry[];
+ const countriesWithMultipleTeamsSet = new Set(countriesWithMultipleTeams);
+
+ const newMatchdays: (readonly [number, number])[][][] = [];
+ for (const [matchdayIndex, md] of matchdays.entries()) {
+ const days = Array.from(
+ {
+ // TODO: remove this hardcode
+ length:
+ tournament === 'cl' && matchdayIndex === 0
+ ? 3
+ : matchdayIndex === numMatchdays - 1
+ ? 1
+ : 2,
+ },
+ () => [] as (readonly [number, number])[],
+ );
+ const numGamesPerDay = matchdaySize / days.length;
+
+ // TODO: build a graph of paired teams
+
+ for (const pair of tvPairings) {
+ for (const t of shuffle(pair)) {
+ const pairedT = t === pair[0] ? pair[1] : pair[0];
+ const team = teams[t];
+ const teamCountry = team.country;
+ const match = md.find(m => m[0] === t || m[1] === t);
+ if (!match) {
+ // Already allocated
+ continue;
+ }
+ const nonFullDays = days.filter(day => day.length < numGamesPerDay);
+ const minDay = minBy(shuffle(nonFullDays), day =>
+ sumBy(day, m =>
+ m[0] === pairedT || m[1] === pairedT
+ ? 1000000
+ : (teams[m[0]].country === teamCountry ? 1 : 0) +
+ (teams[m[1]].country === teamCountry ? 1 : 0),
+ ),
+ )!;
+ minDay.push(match);
+ pull(md, match);
+ }
+ }
+
+ const remainingTeams = md.flat();
+ const orderedRemainingTeams = orderBy(
+ shuffle(remainingTeams),
+ t => countriesWithMultipleTeamsSet.has(teams[t].country),
+ 'desc',
+ );
+ for (const t of orderedRemainingTeams) {
+ const team = teams[t];
+ const teamCountry = team.country;
+ const match = md.find(p => p[0] === t || p[1] === t);
+ if (!match) {
+ // Already allocated
+ continue;
+ }
+ const nonFullDays = days.filter(day => day.length < numGamesPerDay);
+ const minDay = minBy(shuffle(nonFullDays), day =>
+ sumBy(
+ day,
+ m =>
+ (teams[m[0]].country === teamCountry ? 1 : 0) +
+ (teams[m[1]].country === teamCountry ? 1 : 0),
+ ),
+ )!;
+ minDay.push(match);
+ pull(md, match);
+ }
+
+ newMatchdays.push(shuffle(days.map(day => shuffle(day))));
+ }
+
+ return newMatchdays;
+};
diff --git a/src/model/getPairings.ts b/src/model/getPairings.ts
index 93df7c70..4cdee433 100644
--- a/src/model/getPairings.ts
+++ b/src/model/getPairings.ts
@@ -5,7 +5,7 @@ export default async (season: number, tournament: Tournament) => {
try {
const pairings = await import(
/* webpackChunkName: "pairings/[request]" */
- `../data/${tournament}/gs/${season}/pairings.txt`
+ `../data/${tournament}/${season < 2024 ? 'gs' : 'ls'}/${season}/pairings.txt`
);
return (pairings.default as string)
.trim()
diff --git a/src/pages/cl/ls/index.tsx b/src/pages/cl/ls/index.tsx
index 3a316e21..6f70f77d 100644
--- a/src/pages/cl/ls/index.tsx
+++ b/src/pages/cl/ls/index.tsx
@@ -6,7 +6,12 @@ import LeagueStage from '#containers/LeagueStage/index';
type Props = React.ComponentProps;
function CLLS(props: Props) {
- return ;
+ return (
+
+ );
}
export default memo(CLLS);
diff --git a/src/pages/el/ls/index.tsx b/src/pages/el/ls/index.tsx
index 87bfc9b1..4d546e78 100644
--- a/src/pages/el/ls/index.tsx
+++ b/src/pages/el/ls/index.tsx
@@ -6,7 +6,12 @@ import LeagueStage from '#containers/LeagueStage/index';
type Props = React.ComponentProps;
function ELLS(props: Props) {
- return ;
+ return (
+
+ );
}
export default memo(ELLS);
diff --git a/src/routes/Pages/getPotsFromBert.ts b/src/routes/Pages/getPotsFromBert.ts
index 9ea85fa4..84ba6aca 100644
--- a/src/routes/Pages/getPotsFromBert.ts
+++ b/src/routes/Pages/getPotsFromBert.ts
@@ -19,7 +19,25 @@ async function getPotsFromBert(
getPairings(season, tournament),
]);
- return stage === 'ko' ? parseKo(data) : parseGS(data, pairings);
+ const parsedPots = stage === 'ko' ? parseKo(data) : parseGS(data, pairings);
+
+ const flatTeams = parsedPots.flat();
+ const parsedPairings = pairings.map(([a, b]) => {
+ const firstTeam = flatTeams.find(t => t.name === a);
+ if (!firstTeam) {
+ throw new Error(`Team not found: ${a}`);
+ }
+ const secondTeam = flatTeams.find(t => t.name === b);
+ if (!secondTeam) {
+ throw new Error(`Team not found: ${b}`);
+ }
+ return [firstTeam, secondTeam] as const;
+ });
+
+ return {
+ pots: parsedPots,
+ pairings: parsedPairings,
+ };
}
export default memoize(getPotsFromBert, (...args) => args.join(':'));
diff --git a/src/routes/Pages/getWcPots.ts b/src/routes/Pages/getWcPots.ts
index 69b09518..cef7fa9d 100644
--- a/src/routes/Pages/getWcPots.ts
+++ b/src/routes/Pages/getWcPots.ts
@@ -10,7 +10,10 @@ async function getWcPots(season: number) {
.trim()
.split('\n\n')
.map(line => line.trim().split('\n'));
- return parseWc(ths, rest); // TODO: only works with 'default' right now
+ const parsedPots = parseWc(ths, rest); // TODO: only works with 'default' right now
+ return {
+ pots: parsedPots,
+ };
}
export default memoize(getWcPots);
diff --git a/src/routes/Pages/index.tsx b/src/routes/Pages/index.tsx
index 8a29f26e..f8b04c8b 100644
--- a/src/routes/Pages/index.tsx
+++ b/src/routes/Pages/index.tsx
@@ -43,6 +43,7 @@ interface Props {
interface State {
Page: React.ComponentType | null;
pots: readonly (readonly Team[])[] | null;
+ pairings?: readonly (readonly [Team, Team])[];
// tournament: Tournament,
// stage: Stage,
season: number; // for error handling (so that we know the previous season)
@@ -52,7 +53,7 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) {
const params = useParams();
const [, setPopup] = usePopup();
- const [{ Page, pots }, setState] = useState(initialState);
+ const [{ Page, pots, pairings }, setState] = useState(initialState);
const fetchData = async () => {
setPopup({
@@ -67,7 +68,8 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) {
const newPage = await getPage(tournament, stage);
- const newPots = await potsPromise;
+ const data = await potsPromise;
+ const { pots: newPots } = data;
if (!isFirefox) {
const teamsWithFlags = [
@@ -83,6 +85,7 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) {
setState({
Page: newPage,
pots: newPots,
+ pairings: 'pairings' in data ? data.pairings : undefined,
// tournament,
// stage,
season,
@@ -130,6 +133,7 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) {
stage={params.stage}
season={season}
pots={pots}
+ tvPairings={pairings}
isFirstPotShortDraw={isUefaClubTournament && season >= 2021}
/>
)