Scheduling

RoundRobin

Fair fixtures by the circle method, with home and away balanced, a bye for an odd number and double rounds, and standings with stated tiebreaks from scores entered.

#smart#interactive#fixtures#league#standings#games

Demo

Preview

Summer league

0 of 6 games played, over 3 rounds. Every team has 1 or 2 games at home.

Standings
#TeamPWDLForAgDiffPtsHome/awayTiebreak
1Ada000000002/1
2Bo000000001/2
3Cy000000002/1
4Di000000001/2
  1. Round 1

    • AdaDi
    • BoCy
  2. Round 2

    • CyAda
    • DiBo
  3. Round 3

    • AdaBo
    • CyDi

Five-a-side, with a bye

0 of 10 games played, over 5 rounds. Every team has 2 at home and 2 away.

Standings
#TeamPWDLForAgDiffPtsHome/awayTiebreak
1Ada000000002/2
2Bo000000002/2
3Cy000000002/2
4Di000000002/2
5Ed000000002/2
  1. Round 1

    • DiAda
    • BoCy

    Ed rests this round.

  2. Round 2

    • CyEd
    • AdaBo

    Di rests this round.

  3. Round 3

    • BoDi
    • EdAda

    Cy rests this round.

  4. Round 4

    • AdaCy
    • DiEd

    Bo rests this round.

  5. Round 5

    • EdBo
    • CyDi

    Ada rests this round.

Enter a score to see on_change.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/round_robin.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/round_robin.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/round_robin.qwe
component RoundRobinDemoLive() {    state seen = null    <div.demo-stack>        <RoundRobin id="round-robin-demo-five" title="Five-a-side, with a bye" name="league" win={2} teams={["Ada", "Bo", "Cy", "Di", "Ed"]}                    on_change={(r) => { seen = r }}/>        <p.demo-note>{seen == null ? "Enter a score to see on_change." : `on_change: ${seen.played} of ${seen.matches} played, ${seen.table[0].team} first.`}</p>    </div>}view RoundRobinDemo() =    <div.demo-stack>        <RoundRobin id="round-robin-demo" title="Summer league" teams={["Ada", "Bo", "Cy", "Di"]}/>        <RoundRobinDemoLive/>    </div>

Source

ui/round_robin.qwe
// RoundRobin: everyone plays everyone. The fixtures come from the circle method: one// place stays still and the others turn round it a step each round, so nobody plays// twice in a round and nobody meets anyone twice. With an odd number the still place// is the bye, and each team rests once. Home and away are as even as they can be:// with an odd number every team has as many of each, with an even number they differ by// one. A double round plays the same rounds again the other way about. Scores// entered go into a table sorted on points, then by the tiebreaks chosen, and the// table says what separated two teams level on points, or that nothing did.////   <RoundRobin teams={["Ada", "Bo", "Cy", "Di"]}/>//   <RoundRobin teams={clubs} double={true} win={2} tiebreak="difference"//               on_change={(r) => { leader = r.table[0].team }}/>//// teams: the names, a list or a line each in text; the list can be edited, and// scores stay with the fixture they were entered for. double: starts with each pair// meeting twice. scores: scores to start with, a map from "Home>Away" to { home,// away }. win, draw, loss: points for each (3, 1, 0). tiebreak: "head" (points in the// games between those level, then goal difference in them, then overall difference and goals// scored: the default) or "difference" (overall difference and goals first, then// the games between them). title: the heading ("Round robin"). id: a prefix for its ids// ("round-robin"), unique on the page. The fields are named teams, double, tiebreak,// and score_<round>_<n>_home and _away; name: a prefix for those. on_change(r): inside one of// your components, given { table, played, matches } after each change.//// round_robin_fixtures(teams, double) gives { rounds, balance }: each round// { number, games, rest }, each game { id, home, away }, and each balance line// { team, home, away }. round_robin_table(teams, games, points, tiebreak) gives the table// from games carrying home_score and away_score: { team, played, won, drawn, lost, gf,// ga, difference, points, why, level }. It takes 24 teams, and says so.import ui/theme.qwe use ui_clslet round_robin_css = css`.ui-rrobin { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; max-width: 720px; padding: 20px 22px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-rrobin .ui-rrobin-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-rrobin-bar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; }.ui-rrobin-field { display: grid; gap: 5px; min-width: 0; font-size: 13px; font-weight: 500; }.ui-rrobin-options { display: grid; gap: 10px; }.ui-rrobin textarea, .ui-rrobin select { box-sizing: border-box; width: 100%; min-width: 0; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); }.ui-rrobin select { height: 38px; }.ui-rrobin textarea { min-height: 96px; padding: 8px 10px; resize: vertical; }.ui-rrobin textarea:focus-visible, .ui-rrobin select:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-rrobin-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }.ui-rrobin-check input { margin: 0; accent-color: var(--ui-accent); }.ui-rrobin-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-rrobin-error { margin: 0; color: var(--ui-danger); font-size: 13px; }.ui-rrobin-wrap { overflow-x: auto; }.ui-rrobin-wrap:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-rrobin-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }.ui-rrobin-table th, .ui-rrobin-table td { padding: 6px 8px; border-bottom: 1px solid var(--ui-border); text-align: right; white-space: nowrap; }.ui-rrobin-table thead th { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-rrobin-table .ui-rrobin-team { text-align: left; font-weight: 600; }.ui-rrobin-table .ui-rrobin-why { color: var(--ui-muted); font-size: 12px; font-weight: 400; text-align: left; white-space: normal; }.ui-rrobin-points { font-weight: 700; }.ui-rrobin-rounds { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }.ui-rrobin-round { display: grid; gap: 6px; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-rrobin .ui-rrobin-round-name { margin: 0; font-size: 13px; font-weight: 650; }.ui-rrobin-games { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }.ui-rrobin-game { display: grid; grid-template-columns: minmax(0, 1fr) 48px auto 48px minmax(0, 1fr); gap: 6px; align-items: center; }.ui-rrobin-home { overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; }.ui-rrobin-away { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-rrobin-game input { box-sizing: border-box; width: 100%; height: 30px; padding: 0 4px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums; text-align: center; }.ui-rrobin-game input:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-rrobin-game input[aria-invalid] { border-color: var(--ui-danger); }.ui-rrobin-rest { margin: 0; color: var(--ui-muted); font-size: 12px; }@media (max-width: 480px) { .ui-rrobin-bar { grid-template-columns: minmax(0, 1fr); } .ui-rrobin-game { grid-template-columns: minmax(0, 1fr) 40px auto 40px minmax(0, 1fr); } }`let round_robin_most = 24let round_robin_labels = { head: "the games between them", head_difference: "goal difference in the games between them", difference: "goal difference", scored: "goals scored" }// The rounds of one go through the teams. `ring` turns; `still` stays (null when the// number is odd: the team set against it rests). Two teams in the ring are the same// distance apart whichever round they meet, and of the two ways round the ring exactly one is// short, as its size is odd, so "the team the short way behind is at home" gives// every team the same number of home games within the ring.fn round_robin_cycle(teams: list) {    let n = len(teams)    let odd = n % 2 == 1    let still = odd ? null : teams[0]    let ring = odd ? teams : slice(teams, 1, n)    let size = len(ring)    let half = int((size - 1) / 2)    for r in 0..size {        let at = (p: int) => ring[(p - r + size + size) % size]        let games = []        let rest = null        if odd { rest = at(size - 1) } else {            // The still team alternates, so it too ends within one.            games = games | push(r % 2 == 0 ? { home: still, away: at(size - 1) } : { home: at(size - 1), away: still })        }        for i in 1..(half + 1) {            let x = at(i - 1)            let y = at(size - 1 - i)            let ahead = size - i - i            games = games | push(ahead <= half ? { home: x, away: y } : { home: y, away: x })        }        { number: r + 1, games, rest }    }}fn round_robin_fixtures(teams: list, double: bool = false) {    if len(teams) < 2 { return { rounds: [], balance: [] } }    let once = round_robin_cycle(teams)    let count = len(once)    let back = once | map((r) => { number: r.number + count, games: r.games | map((g) => { home: g.away, away: g.home }), rest: r.rest })    let rounds = (double ? once + back : once) | map((r) => { ...r, games: r.games | map((g) => { ...g, id: `${g.home}>${g.away}` }) })    let games = rounds | map((r) => r.games) | flatten()    let balance = teams | map((t) => { team: t, home: games | filter((g) => g.home == t) | len, away: games | filter((g) => g.away == t) | len })    { rounds, balance }}// A score as typed: an int, null when empty, an error value when it is not a score.fn round_robin_score(text) {    let t = trim(str(text ?? ""))    if t == "" { return null }    re.test(t, "^\\d{1,3}$") ? int(t) : err("a score is a whole number from 0 to 999")}// Plain records for these teams from these games.fn round_robin_records(teams: list, games: list, points) {    let start = reduce(teams, {}, (m, t) => set(m, t, { team: t, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, points: 0 }))    let add = (m, t: str, us: int, them: int) => {        let r = m[t]        set(m, t, { team: t, played: r.played + 1, won: r.won + (us > them ? 1 : 0), drawn: r.drawn + (us == them ? 1 : 0), lost: r.lost + (us < them ? 1 : 0),                    gf: r.gf + us, ga: r.ga + them, points: r.points + (us > them ? points.win : us == them ? points.draw : points.loss) })    }    let done = reduce(games, start, (m, g) => add(add(m, g.home, g.home_score, g.away_score), g.away, g.away_score, g.home_score))    teams | map((t) => { ...done[t], difference: done[t].gf - done[t].ga, why: null, level: false })}// Sorts those level so far by each tiebreak in turn, and notes on the row above each// break what made it. The games between them are worked out afresh for each group,// as who is level changes what counts.fn round_robin_split(rows: list, order: list, games: list, points) {    if len(rows) < 2 { return rows }    if len(order) == 0 { return rows | map((r) => { ...r, level: true }) }    let by = order[0]    let rest = slice(order, 1, len(order))    let names = rows | map((r) => r.team)    let among = games | filter((g) => contains(names, g.home) && contains(names, g.away))    let inside = round_robin_records(names, among, points)    let value_of = (r) => match by {        "head" => find(inside, (x) => x.team == r.team).points        "head_difference" => find(inside, (x) => x.team == r.team).difference        "difference" => r.difference        _ => r.gf    }    let scored = rows | map((r) => ({ row: r, v: value_of(r) })) | sort_by((x) => -x.v)    let groups = []    let group = []    for x in scored {        if len(group) > 0 && group[0].v != x.v {            groups = groups | push(group)            group = []        }        group = group | push(x)    }    groups = groups | push(group)    let out = []    for i in 0..len(groups) {        let settled = round_robin_split(groups[i] | map((x) => x.row), rest, games, points)        let closing = i + 1 < len(groups) ? set(settled, len(settled) - 1, { ...last(settled), why: by }) : settled        out = out + closing    }    out}fn round_robin_table(teams: list, games: list, points, tiebreak: str = "head") {    let rows = round_robin_records(teams, games, points)    let order = tiebreak == "difference" ? ["difference", "scored", "head", "head_difference"] : ["head", "head_difference", "difference", "scored"]    let values_ = rows | map((r) => r.points) | unique() | sort() | reverse()    values_ | map((p) => round_robin_split(rows | filter((r) => r.points == p), order, games, points)) | flatten()}fn round_robin_read(text: str) {    let lines = split(replace(text, "\r", ""), "\n") | map((l) => trim(l)) | filter((l) => l != "")    let teams = []    let problems = []    for l in lines {        if any(teams, (x) => lower(x) == lower(l)) {            problems = problems | push(`${slice(l, 0, 40)} is on the list twice; once is enough.`)        } else {            teams = teams | push(slice(l, 0, 60))        }    }    if len(teams) > round_robin_most {        problems = problems | push(`Only the first ${round_robin_most} teams are drawn: ${len(teams) - round_robin_most} left out.`)        teams = teams | take(round_robin_most)    }    { teams, problems }}// The games with both scores good, and what is wrong with the others.fn round_robin_played(rounds: list, scores) {    let games = []    let problems = []    for r in rounds {        for g in r.games {            let s = get(scores, g.id, null)            let h = round_robin_score(s.home)            let a = round_robin_score(s.away)            if is_err(h) || is_err(a) {                problems = problems | push(`Round ${r.number}, ${g.home} v ${g.away}: ${is_err(h) ? h.error : a.error}.`)            } else if h != null && a != null {                games = games | push({ home: g.home, away: g.away, home_score: h, away_score: a })            }        }    }    { games, problems }}fn round_robin_balance_text(balance: list) {    if len(balance) == 0 { return "" }    let homes = balance | map((b) => b.home)    let low = min(homes)    let high = max(homes)    low == high ? `Every team has ${low} at home and ${balance[0].away} away.` : `Every team has ${low} or ${high} games at home.`}fn round_robin_why(r, next) {    if next == null || next.points != r.points || next.played == 0 && r.played == 0 { return "" }    if r.level { return `Level with ${next.team} on everything: listed order.` }    r.why == null ? "" : `Ahead of ${next.team} on ${round_robin_labels[r.why]}.`}fn round_robin_sign(n: int) = n > 0 ? `+${n}` : str(n)component RoundRobin(teams = [], double: bool = false, scores = null, win: int = 3, draw: int = 1, loss: int = 0, tiebreak: str = "head", title: str = "Round robin",                     id: str = "round-robin", name: str? = null, on_change = null, class: str? = null) {    style global round_robin_css    state text = type_of(teams) == "list" ? join(teams | map((t) => str(t)), "\n") : str(teams ?? "")    state twice = double    state marks = scores ?? {}    state rule = tiebreak == "difference" ? "difference" : "head"    let named = (n: str) => name != null ? `${name}_${n}` : n    let points = { win, draw, loss }    let read = round_robin_read(text)    let f = round_robin_fixtures(read.teams, twice)    let played = round_robin_played(f.rounds, marks)    let table = round_robin_table(read.teams, played.games, points, rule)    let total = f.rounds | map((r) => len(r.games)) | sum()    let bad = (g, side: str) => {        let s = get(marks, g.id, null)        is_err(round_robin_score(side == "home" ? s.home : s.away))    }    let tell = () => {        if on_change != null {            let r = round_robin_read(text)            let k = round_robin_fixtures(r.teams, twice)            let p = round_robin_played(k.rounds, marks)            on_change({ table: round_robin_table(r.teams, p.games, points, rule), played: len(p.games), matches: k.rounds | map((x) => len(x.games)) | sum() })        }    }    let enter = (g, side: str, v: str) => {        let s = get(marks, g.id, null) ?? { home: "", away: "" }        marks = set(marks, g.id, set(s, side, v))        tell()    }    <section class={ui_cls(["ui-rrobin", class])} aria-labelledby={`${id}-title`}>        <h3.ui-rrobin-title id={`${id}-title`}>{title}</h3>        <div.ui-rrobin-bar>            <label.ui-rrobin-field>                Teams, one a line                <textarea id={`${id}-teams`} name={named("teams")} value={text} rows="4" spellcheck="false" @input={(e) => { text = e.value; tell() }}></textarea>            </label>            <div.ui-rrobin-options>                <label.ui-rrobin-check>                    <input type="checkbox" id={`${id}-double`} name={named("double")} checked={twice} @change={(e) => { twice = e.checked; tell() }}/>                    Double round: home and away against each                </label>                <label.ui-rrobin-field>                    Level on points, sort by                    <select id={`${id}-rule`} name={named("tiebreak")} @change={(e) => { rule = e.value; tell() }}>                        <option value="head" selected={rule == "head"}>The games between them first</option>                        <option value="difference" selected={rule == "difference"}>Goal difference first</option>                    </select>                </label>            </div>        </div>        {for p in read.problems => <p.ui-rrobin-error role="alert">{p}</p>}        {for p in played.problems | take(5) => <p.ui-rrobin-error role="alert">{p}</p>}        {len(read.teams) < 2 && <p.ui-rrobin-note>Add at least two teams to make the fixtures.</p>}        {len(read.teams) >= 2 && <p.ui-rrobin-note aria-live="polite">{`${len(played.games)} of ${total} games played, over ${len(f.rounds)} rounds. ${round_robin_balance_text(f.balance)}`}</p>}        {len(read.teams) >= 2 && <div.ui-rrobin-wrap tabindex="0" role="group" aria-label="Standings">            <table.ui-rrobin-table>                <caption class="ui-sr">Standings</caption>                <thead><tr>                    <th scope="col">#</th><th.ui-rrobin-team scope="col">Team</th><th scope="col">P</th><th scope="col">W</th><th scope="col">D</th><th scope="col">L</th>                    <th scope="col">For</th><th scope="col">Ag</th><th scope="col">Diff</th><th scope="col">Pts</th><th scope="col">Home/away</th><th.ui-rrobin-why scope="col">Tiebreak</th>                </tr></thead>                <tbody>                    {for i in 0..len(table) {                        let r = table[i]                        let team = r.team                        let b = find(f.balance, (x) => x.team == team)                        <tr key={team}>                            <td>{str(i + 1)}</td><th.ui-rrobin-team scope="row">{team}</th><td>{str(r.played)}</td><td>{str(r.won)}</td><td>{str(r.drawn)}</td><td>{str(r.lost)}</td>                            <td>{str(r.gf)}</td><td>{str(r.ga)}</td><td>{round_robin_sign(r.difference)}</td><td.ui-rrobin-points>{str(r.points)}</td>                            <td>{`${b.home}/${b.away}`}</td><td.ui-rrobin-why>{round_robin_why(r, table[i + 1])}</td>                        </tr>                    }}                </tbody>            </table>        </div>}        {len(f.rounds) > 0 && <ol.ui-rrobin-rounds>            {for r in f.rounds {                let n = r.number                <li.ui-rrobin-round key={n}>                    <h4.ui-rrobin-round-name>{`Round ${n}`}</h4>                    <ul.ui-rrobin-games>                        {for i in 0..len(r.games) {                            let g = r.games[i]                            let gid = g.id                            <li.ui-rrobin-game key={gid}>                                <span.ui-rrobin-home>{g.home}</span>                                <input type="text" inputmode="numeric" maxlength="3" autocomplete="off" name={named(`score_${n}_${i}_home`)} aria-label={`Round ${n}: ${g.home}'s score against ${g.away}`}                                       value={str(get(marks, gid, null).home ?? "")} aria-invalid={bad(g, "home") ? "true" : null} @input={(e) => { enter(g, "home", e.value) }}/>                                <span aria-hidden="true">–</span>                                <input type="text" inputmode="numeric" maxlength="3" autocomplete="off" name={named(`score_${n}_${i}_away`)} aria-label={`Round ${n}: ${g.away}'s score against ${g.home}`}                                       value={str(get(marks, gid, null).away ?? "")} aria-invalid={bad(g, "away") ? "true" : null} @input={(e) => { enter(g, "away", e.value) }}/>                                <span.ui-rrobin-away>{g.away}</span>                            </li>                        }}                    </ul>                    {r.rest != null && <p.ui-rrobin-rest>{`${r.rest} rests this round.`}</p>}                </li>            }}        </ol>}    </section>}
Thread
36Useful? Like it, or add to its thread below.

Thread 0 posts

On RoundRobin: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.