starting to have a bracket system that is previewable

This commit is contained in:
unurled 2025-07-14 20:48:29 +02:00
parent 62127cc5e4
commit 82ecf80068
Signed by: unurled
GPG key ID: EFC5F5E709B47DDD
82 changed files with 3461 additions and 637 deletions

View file

@ -10,24 +10,40 @@
import { getContext } from 'svelte';
import Nav from '$lib/components/nav.svelte';
import type { User } from '$lib/server/db/schema/users';
import ThemeToggle from './theme-toggle.svelte';
let {
breadcrumbs,
className,
fullWidth = false,
children
}: { breadcrumbs: BreadcrumbItemType[]; className?: string; children: any } = $props();
}: {
breadcrumbs: BreadcrumbItemType[];
className?: string;
fullWidth?: boolean;
children: any;
} = $props();
const user = getContext<User>('user');
</script>
<div class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 pt-4 pb-8 sm:px-6 lg:px-8">
<div
class={cn(
'mx-auto flex min-h-screen flex-col px-4 pt-4 pb-8 sm:px-6 lg:px-8',
!fullWidth && 'max-w-7xl'
)}
>
<!-- Header -->
<header class="flex flex-col gap-4 pb-8">
<div class="flex items-center justify-between">
<div class="flex-1">
<header class="flex flex-col gap-4 pb-4">
<div class="flex items-center justify-between border-b pb-4">
<div class="flex">
<a href="/" class="text-primary text-2xl font-bold">FlbxCup</a>
</div>
<div>
<Nav />
</div>
<div class="flex items-center gap-4">
<ThemeToggle />
{#if user}
<div class="text-muted-foreground text-sm">
Welcome, {user.username || 'User'}
@ -38,9 +54,6 @@
{/if}
</div>
</div>
<div class="border-b pb-2">
<Nav />
</div>
</header>
<!-- Breadcrumbs -->

View file

@ -0,0 +1,26 @@
<script lang="ts">
import Button from '$ui/button/button.svelte';
import CardContent from '$ui/card/card-content.svelte';
import CardFooter from '$ui/card/card-footer.svelte';
import CardHeader from '$ui/card/card-header.svelte';
import Card from '$ui/card/card.svelte';
import type { Bracket } from '@/lib/server/db/schema/brackets';
let { id, bracket = $bindable() }: { id: string; bracket: Bracket } = $props();
</script>
<Card id={`round-${id}`}>
<CardHeader>{bracket.name}</CardHeader>
<CardContent>
{#if !bracket.rounds || bracket.rounds.length <= 0}
No matches
{:else}
{bracket.rounds[bracket.position - 1].name} ({bracket.position}/{bracket.rounds.length})
{/if}
</CardContent>
<CardFooter
><Button href={`/competitions/${bracket.competition_id}/bracket/${bracket.id}`}
>View Matches</Button
></CardFooter
>
</Card>

View file

@ -7,6 +7,7 @@
import Label from '$ui/label/label.svelte';
import RadioGroupItem from '$ui/radio-group/radio-group-item.svelte';
import RadioGroup from '$ui/radio-group/radio-group.svelte';
import type { Bracket } from '@/lib/server/db/schema/brackets';
import type { Round } from '@/lib/server/db/schema/rounds';
import { cn, instanceOf } from '@/lib/utils';
import { SchedulingMode } from '@/types';
@ -19,15 +20,17 @@
let {
competitionId,
rounds = $bindable(),
showAddRound = $bindable(true)
}: { competitionId: string; rounds: Round[]; showAddRound: boolean } = $props();
brackets = $bindable(),
showAddBracket = $bindable(true)
}: { competitionId: string; brackets: Bracket[]; showAddBracket: boolean } = $props();
let schedulingMode: SchedulingMode = $state(SchedulingMode.single);
let name = $state('');
let nameInvalid = $state(false);
let nbMatches: number | undefined = $state();
let nbMatchesInvalid = $state(false);
let size: number | undefined = $state();
let sizeInvalid = $state(false);
let buttonDisabled = $state(false);
async function submit() {
if (name.length === 0) {
@ -35,25 +38,27 @@
toast.error('Name is required');
return;
}
if (!nbMatches || nbMatches < 0) {
nbMatchesInvalid = true;
if (!size || size < 0) {
sizeInvalid = true;
toast.error('Number of matches must be greater than 0');
return;
}
nameInvalid = false;
// loading/disable button
buttonDisabled = true;
const response = await fetch(`/api/competitions/${competitionId}`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ scheduling_mode: schedulingMode, name: name, nb_matches: nbMatches })
body: JSON.stringify({ scheduling_mode: schedulingMode, name: name, size: size })
});
console.log('response', response);
// update rounds
const data = await response.json();
if (instanceOf<Round>(data, 'id')) {
rounds = [...rounds, data];
showAddRound = false;
buttonDisabled = false;
if (Array.isArray(data) && data.length > 0 && instanceOf<Bracket>(data[0], 'id')) {
brackets = [...brackets, data[0]];
showAddBracket = false;
} else {
throw new Error('Invalid round');
throw new Error('Invalid bracket');
}
}
</script>
@ -65,8 +70,8 @@
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Label for="nb_matches" class="text-start"
>Number of matches<span class="text-red-500">*</span><CircleQuestionMarkIcon /></Label
<Label for="size" class="text-start"
>Size<span class="text-red-500">*</span><CircleQuestionMarkIcon /></Label
>
</TooltipTrigger>
<TooltipContent>Number of participants at the beginning of the stage.</TooltipContent>
@ -80,10 +85,10 @@
placeholder="Name"
/>
<Input
aria-invalid={nbMatchesInvalid}
name="nb_matches"
aria-invalid={sizeInvalid}
name="size"
type="number"
bind:value={nbMatches}
bind:value={size}
placeholder="Number of matches"
/>
</div>
@ -110,6 +115,6 @@
{/each}
</RadioGroup>
<div class="flex w-full justify-end">
<Button onclick={submit}>Add Round<ArrowRightIcon /></Button>
<Button disabled={buttonDisabled} onclick={submit}>Add Round<ArrowRightIcon /></Button>
</div>
</div>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import type { MatchWithRelations } from '@/lib/server/db/schema/matches';
import Card from '../ui/card/card.svelte';
import { CardContent } from '../ui/card';
import Input from '../ui/input/input.svelte';
import { MinusIcon, PlusIcon } from 'lucide-svelte';
import Button from '../ui/button/button.svelte';
let {
match,
isFinal,
updateScore
}: {
match: MatchWithRelations;
isFinal: boolean;
updateScore: (score: number, teamId: string) => void;
} = $props();
let matchState = $state(match);
let isCompleted = $derived(match.score1 > 0 || match.score2 > 0);
$effect(() => {
updateScore(matchState.score1, match.team1_id);
});
$effect(() => {
updateScore(matchState.score2, match.team2_id);
});
$effect(() => {
matchState = match;
});
function localUpdateScore(score: number, teamId: string) {
console.log('localUpdateScore', score, teamId);
updateScore(score, teamId);
}
</script>
<Card>
<CardContent>
<div class="match" class:final={isFinal} class:completed={isCompleted}>
<div class="match-content">
<div class="team flex justify-between" class:winner={match.winner_id === match.team1_id}>
<span class="team-name">Team {match.team1?.name}</span>
<div class="flex gap-2">
<Button
variant="outline"
onclick={() => {
console.log('aadazd');
localUpdateScore(matchState.score1 + 1, matchState.team1_id);
}}><PlusIcon /></Button
>
<Input
class="team-score"
type="number"
min="0"
max="100"
bind:value={matchState.score1}
/>
<Button
variant="outline"
onclick={() => {
localUpdateScore(matchState.score1 - 1, matchState.team1_id);
}}><MinusIcon /></Button
>
</div>
</div>
<div
class="team flex justify-between"
class:winner={matchState.winner_id === matchState.team2_id}
>
<span class="team-name">Team {match.team2?.name}</span>
<div class="flex gap-2">
<Button
variant="outline"
onclick={() => {
localUpdateScore(matchState.score2 + 1, matchState.team2_id);
}}><PlusIcon /></Button
>
<Input
class="team-score"
type="number"
min="0"
max="100"
bind:value={matchState.score2}
/>
<Button
variant="outline"
onclick={() => {
localUpdateScore(match.score2 - 1, match.team2_id);
}}><MinusIcon /></Button
>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<style>
.match {
/* border: 2px solid #dee2e6; */
/* background: white; */
/* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */
overflow: hidden;
transition: all 0.2s ease;
}
.match:hover {
/* box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); */
}
.match.final {
/* border-color: #ffc107; */
/* background: linear-gradient(135deg, #fff3cd, #ffffff); */
}
.match.completed {
/* border-color: #28a745; */
}
.match-header {
/* background: #f8f9fa; */
/* border-bottom: 1px solid #dee2e6; */
text-align: center;
}
.match-date {
font-size: 0.8em;
/* color: #6c757d; */
}
.match-content {
padding: 0;
}
.team {
display: flex;
justify-content: space-between;
align-items: center;
/* border-bottom: 1px solid #dee2e6; */
transition: background-color 0.2s ease;
}
.team:last-child {
border-bottom: none;
}
.team.winner {
/* background: #d4edda; */
font-weight: 600;
}
.team-name {
flex: 1;
font-size: 0.9em;
}
.team-score {
font-weight: 600;
/* color: #495057; */
text-align: center;
/* background: #f8f9fa; */
}
.team.winner .team-score {
/* background: #c3e6cb; */
/* color: #155724; */
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { Round, RoundWithMatches } from '@/lib/server/db/schema/rounds';
import type { Snippet } from 'svelte';
let { round, children } = $props();
</script>
<div class="round w-full">
<div class="round-header mb-4">
<h3>{round.name}</h3>
</div>
<div class="flex flex-1 flex-col justify-center gap-4">
{@render children()}
</div>
</div>
<style>
.round {
display: flex;
flex-direction: column;
}
.round-header {
text-align: center;
/* border-bottom: 2px solid #007bff; */
}
.round-header h3 {
margin: 0;
/* color: #007bff; */
font-weight: 600;
}
.round-matches {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
</style>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import type { BracketWithRelations } from '@/lib/server/db/schema/brackets';
import Round from '../round.svelte';
import Match from '../match.svelte';
import type { Team } from '@/lib/server/db/schema/teams';
let { bracket, teams }: { bracket: BracketWithRelations; teams: Team[] } = $props();
function updateScore(score: number, teamId: string) {
console.log('updateScore', score, teamId);
}
</script>
{#if bracket && bracket.rounds}
<div class="bracket">
<div class="bracket-header">
<h1 class="bracket-header">{bracket.name}</h1>
</div>
<div class="bracket-game w-full">
{#each bracket.rounds as round, index}
{#if round && round.matches}
<Round {round}>
{#each round.matches as match}
<Match {updateScore} {match} isFinal={index === bracket.rounds.length - 1} />
{/each}
</Round>
{/if}
{/each}
</div>
</div>
{/if}
<style>
.bracket {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.bracket-header {
text-align: center;
/* background: #f8f9fa; */
/* border-bottom: 1px solid #dee2e6; */
}
.bracket-game {
display: flex;
flex: 1;
overflow-x: auto;
padding: 2em;
}
</style>

View file

@ -1,8 +0,0 @@
import { SchedulingMode } from '@/types';
import z from 'zod';
export const formSchema = z.object({
scheduling_mode: z.nativeEnum(SchedulingMode)
});
export type FormSchema = typeof formSchema;

View file

@ -1,29 +0,0 @@
<script lang="ts">
import Button from '$ui/button/button.svelte';
import CardContent from '$ui/card/card-content.svelte';
import CardFooter from '$ui/card/card-footer.svelte';
import CardHeader from '$ui/card/card-header.svelte';
import Card from '$ui/card/card.svelte';
import type { RoundWithRelations } from '@/lib/server/db/schema/rounds';
import { redirect } from '@sveltejs/kit';
let { id, round, round_number }: { id: string; round: RoundWithRelations; round_number: number } =
$props();
</script>
<Card id={`round-${id}`}>
<CardHeader>Round {round.name}</CardHeader>
<CardContent>
{#if !round.matches || round.matches.length <= 0}
No matches, add some !
{:else}
Matches :{#each round.matches as match (match.id)}
{JSON.stringify(match)}
{/each}
{/if}
</CardContent>
<CardFooter
><Button href={`/competitions/${round.competition_id}/rounds/${round.id}`}>View Matches</Button
></CardFooter
>
</Card>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { toggleMode } from 'mode-watcher';
import { Button } from '$lib/components/ui/button/index.js';
</script>
<Button onclick={toggleMode} variant="outline" size="icon">
<SunIcon
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>

View file

@ -1,36 +1,36 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: 'default',
size: 'default'
}
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
@ -42,11 +42,11 @@
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = 'button',
type = "button",
disabled,
children,
...restProps
@ -60,7 +60,7 @@
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>

View file

@ -2,8 +2,8 @@ import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
buttonVariants,
} from "./button.svelte";
export {
Root,
@ -13,5 +13,5 @@ export {
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant
type ButtonVariant,
};

View file

@ -1,9 +1,9 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import type Calendar from './calendar.svelte';
import CalendarMonthSelect from './calendar-month-select.svelte';
import CalendarYearSelect from './calendar-year-select.svelte';
import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
@ -14,13 +14,13 @@
month,
locale,
placeholder = $bindable(),
monthIndex = 0
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
months: ComponentProps<typeof CalendarMonthSelect>['months'];
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
years: ComponentProps<typeof CalendarYearSelect>['years'];
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
@ -29,13 +29,13 @@
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
@ -58,15 +58,15 @@
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === 'dropdown'}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === 'dropdown-months'}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === 'dropdown-years'}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -12,7 +12,7 @@
<CalendarPrimitive.Cell
bind:ref
class={cn(
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md',
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
className
)}
{...restProps}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
@ -13,22 +13,22 @@
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: 'ghost' }),
'flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
'data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground',
buttonVariants({ variant: "ghost" }),
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
'dark:hover:text-accent-foreground',
"dark:hover:text-accent-foreground",
// focus
'focus:border-ring focus:ring-ring/50 focus:relative',
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
'[&>span]:text-xs [&>span]:opacity-70',
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -9,4 +9,4 @@
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -11,6 +11,6 @@
<CalendarPrimitive.Grid
bind:ref
class={cn('mt-4 flex w-full border-collapse flex-col gap-1', className)}
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -12,7 +12,7 @@
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
'text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal',
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -12,7 +12,7 @@
<CalendarPrimitive.Header
bind:ref
class={cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -11,6 +11,6 @@
<CalendarPrimitive.Heading
bind:ref
class={cn('px-(--cell-size) text-sm font-medium', className)}
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
@ -14,7 +14,7 @@
<span
class={cn(
'has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]',
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
className
)}
>
@ -33,7 +33,7 @@
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5"
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { type WithElementRef, cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn('flex flex-col', className)}>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -12,7 +12,7 @@
<div
bind:this={ref}
class={cn('relative flex flex-col gap-4 md:flex-row', className)}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
@ -13,7 +13,7 @@
<nav
{...restProps}
bind:this={ref}
class={cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', className)}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>

View file

@ -1,14 +1,14 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = 'ghost',
variant = "ghost",
...restProps
}: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
@ -23,7 +23,7 @@
bind:ref
class={cn(
buttonVariants({ variant }),
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}

View file

@ -1,14 +1,14 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = 'ghost',
variant = "ghost",
...restProps
}: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
@ -23,7 +23,7 @@
bind:ref
class={cn(
buttonVariants({ variant }),
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
@ -13,7 +13,7 @@
<span
class={cn(
'has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]',
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
className
)}
>
@ -32,7 +32,7 @@
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5"
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}

View file

@ -1,41 +1,41 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import * as Calendar from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ButtonVariant } from '../button/button.svelte';
import { isEqualMonth, type DateValue } from '@internationalized/date';
import type { Snippet } from 'svelte';
import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ButtonVariant } from "../button/button.svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = 'short',
buttonVariant = 'ghost',
captionLayout = 'label',
locale = 'en-US',
weekdayFormat = "short",
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = 'numeric',
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label';
months?: CalendarPrimitive.MonthSelectProps['months'];
years?: CalendarPrimitive.YearSelectProps['years'];
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
months?: CalendarPrimitive.MonthSelectProps["months"];
years?: CalendarPrimitive.YearSelectProps["years"];
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith('dropdown')) return 'short';
return 'long';
if (captionLayout.startsWith("dropdown")) return "short";
return "long";
});
</script>
@ -50,7 +50,7 @@ get along, so we shut typescript up by casting `value` to `never`.
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
className
)}
{locale}
@ -97,7 +97,7 @@ get along, so we shut typescript up by casting `value` to `never`.
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value)
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<Calendar.Day />

View file

@ -1,21 +1,21 @@
import Root from './calendar.svelte';
import Cell from './calendar-cell.svelte';
import Day from './calendar-day.svelte';
import Grid from './calendar-grid.svelte';
import Header from './calendar-header.svelte';
import Months from './calendar-months.svelte';
import GridRow from './calendar-grid-row.svelte';
import Heading from './calendar-heading.svelte';
import GridBody from './calendar-grid-body.svelte';
import GridHead from './calendar-grid-head.svelte';
import HeadCell from './calendar-head-cell.svelte';
import NextButton from './calendar-next-button.svelte';
import PrevButton from './calendar-prev-button.svelte';
import MonthSelect from './calendar-month-select.svelte';
import YearSelect from './calendar-year-select.svelte';
import Month from './calendar-month.svelte';
import Nav from './calendar-nav.svelte';
import Caption from './calendar-caption.svelte';
import Root from "./calendar.svelte";
import Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
export {
Day,
@ -36,5 +36,5 @@ export {
MonthSelect,
Caption,
//
Root as Calendar
Root as Calendar,
};

View file

@ -1,10 +1,10 @@
import Root from './pagination.svelte';
import Content from './pagination-content.svelte';
import Item from './pagination-item.svelte';
import Link from './pagination-link.svelte';
import PrevButton from './pagination-prev-button.svelte';
import NextButton from './pagination-next-button.svelte';
import Ellipsis from './pagination-ellipsis.svelte';
import Root from "./pagination.svelte";
import Content from "./pagination-content.svelte";
import Item from "./pagination-item.svelte";
import Link from "./pagination-link.svelte";
import PrevButton from "./pagination-prev-button.svelte";
import NextButton from "./pagination-next-button.svelte";
import Ellipsis from "./pagination-ellipsis.svelte";
export {
Root,
@ -21,5 +21,5 @@ export {
Link as PaginationLink,
PrevButton as PaginationPrevButton,
NextButton as PaginationNextButton,
Ellipsis as PaginationEllipsis
Ellipsis as PaginationEllipsis,
};

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -13,7 +13,7 @@
<ul
bind:this={ref}
data-slot="pagination-content"
class={cn('flex flex-row items-center gap-1', className)}
class={cn("flex flex-row items-center gap-1", className)}
{...restProps}
>
{@render children?.()}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
@ -14,7 +14,7 @@
bind:this={ref}
aria-hidden="true"
data-slot="pagination-ellipsis"
class={cn('flex size-9 items-center justify-center', className)}
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import type { WithElementRef } from '$lib/utils.js';
import type { HTMLLiAttributes } from "svelte/elements";
import type { WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),

View file

@ -1,12 +1,12 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { type Props, buttonVariants } from '$lib/components/ui/button/index.js';
import { Pagination as PaginationPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { type Props, buttonVariants } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
size = 'icon',
size = "icon",
isActive,
page,
children,
@ -24,13 +24,13 @@
<PaginationPrimitive.Page
bind:ref
{page}
aria-current={isActive ? 'page' : undefined}
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
class={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Pagination as PaginationPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -22,9 +22,9 @@
aria-label="Go to next page"
class={cn(
buttonVariants({
size: 'default',
variant: 'ghost',
class: 'gap-1 px-2.5 sm:pr-2.5'
size: "default",
variant: "ghost",
class: "gap-1 px-2.5 sm:pr-2.5",
}),
className
)}

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Pagination as PaginationPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -22,9 +22,9 @@
aria-label="Go to previous page"
class={cn(
buttonVariants({
size: 'default',
variant: 'ghost',
class: 'gap-1 px-2.5 sm:pl-2.5'
size: "default",
variant: "ghost",
class: "gap-1 px-2.5 sm:pl-2.5",
}),
className
)}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import { Pagination as PaginationPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js';
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
@ -20,7 +20,7 @@
role="navigation"
aria-label="pagination"
data-slot="pagination"
class={cn('mx-auto flex w-full justify-center', className)}
class={cn("mx-auto flex w-full justify-center", className)}
{count}
{perPage}
{siblingCount}

View file

@ -7,6 +7,7 @@ import { env } from '$env/dynamic/private';
import { env as envPublic } from '$env/dynamic/public';
import { sessions, type Session } from './db/schema/sessions';
import { users } from './db/schema/users';
import { v7 } from 'uuid';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
@ -20,20 +21,7 @@ export const keycloak = new KeyCloak(
);
function generateSecureRandomString(): string {
// Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion)
const alphabet = 'abcdefghijklmnpqrstuvwxyz23456789';
// Generate 24 bytes = 192 bits of entropy.
// We're only going to use 5 bits per byte so the total entropy will be 192 * 5 / 8 = 120 bits
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
let id = '';
for (let i = 0; i < bytes.length; i++) {
// >> 3 s"removes" the right-most 3 bits of the byte
id += alphabet[bytes[i] >> 3];
}
return id;
return v7();
}
async function hashSecret(secret: string): Promise<Uint8Array> {

View file

@ -1,5 +1,4 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/node-postgres';
import { env } from '$env/dynamic/private';
import * as brackets from './schema/brackets';
import * as breakperiods from './schema/breakperiods';
@ -12,10 +11,13 @@ import * as rounds from './schema/rounds';
import * as sessions from './schema/sessions';
import * as teams from './schema/teams';
import * as users from './schema/users';
import { Pool } from 'pg';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = postgres(env.DATABASE_URL);
const pool = new Pool({
connectionString: env.DATABASE_URL
});
export const schema = {
...brackets,
@ -31,6 +33,4 @@ export const schema = {
...users
};
export const db = drizzle(client, {
schema: schema
});
export const db = drizzle({ client: pool, schema: schema });

View file

@ -0,0 +1,51 @@
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '..';
import { brackets, type BracketInsert } from '../schema/brackets';
export async function insertBracket(competitionId: string, bracket: BracketInsert) {
return await db.insert(brackets).values(bracket).returning();
}
export async function getBrackets(competitionId: string) {
return await db.select().from(brackets).where(eq(brackets.competition_id, competitionId));
}
export async function getBracket(bracketId: string) {
return await db.query.brackets.findFirst({
where: eq(brackets.id, bracketId)
});
}
export async function getBracketWithRoundsAndMatches(bracketId: string) {
return await db.query.brackets.findFirst({
where: eq(brackets.id, bracketId),
with: {
rounds: {
with: {
matches: {
with: {
team1: true,
team2: true
}
}
}
}
}
});
}
export async function getBracketsWithRounds(competitionId: string) {
return await db.query.brackets.findMany({
where: eq(brackets.competition_id, competitionId),
with: {
rounds: true
}
});
}
export async function getBracketsByListIds(bracketIds: string[], withRelations: object = {}) {
return await db.query.brackets.findMany({
where: inArray(brackets.id, bracketIds),
with: withRelations
});
}

View file

@ -14,9 +14,13 @@ export async function getCompetitionWithAll(id: string) {
with: {
breakperiods: true,
fields: true,
rounds: {
brackets: {
with: {
matches: true
rounds: {
with: {
matches: true
}
}
}
},
teams: true
@ -30,9 +34,13 @@ export async function getCompetitionsWithAll(skip: number = 0, take: number = 10
with: {
breakperiods: true,
fields: true,
rounds: {
brackets: {
with: {
matches: true
rounds: {
with: {
matches: true
}
}
}
},
teams: true

View file

@ -2,24 +2,21 @@ import { and, eq } from 'drizzle-orm';
import { db } from '..';
import { rounds, type RoundInsert } from '../schema/rounds';
export async function insertRound(competitionId: string, round: RoundInsert) {
export async function insertRound(round: RoundInsert) {
return await db.insert(rounds).values(round).returning();
}
export async function getRounds(competitionId: string) {
return await db.select().from(rounds).where(eq(rounds.competition_id, competitionId));
export async function getRounds(bracketId: string) {
return await db.select().from(rounds).where(eq(rounds.bracket_id, bracketId));
}
export async function getRound(competitionId: string, roundId: string) {
return await db
.select()
.from(rounds)
.where(and(eq(rounds.competition_id, competitionId), eq(rounds.id, roundId)));
export async function getRound(roundId: string) {
return await db.select().from(rounds).where(eq(rounds.id, roundId));
}
export async function getRoundWithMatches(competitionId: string, roundId: string) {
export async function getRoundWithMatches(bracketId: string, roundId: string) {
return await db.query.rounds.findMany({
where: and(eq(rounds.competition_id, competitionId), eq(rounds.id, roundId)),
where: and(eq(rounds.bracket_id, bracketId), eq(rounds.id, roundId)),
with: {
matches: true
}

View file

@ -1,10 +1,12 @@
import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../util';
import { timestamps, type TModelWithRelations } from '../util';
import { competitions } from './competitions';
import { rounds } from './rounds';
import { rounds, type RoundWithMatches } from './rounds';
import { relations } from 'drizzle-orm';
import { BracketType, enumToPgEnum, SchedulingMode } from '../../../../types';
export const bracketTypes = t.pgEnum('bracket_type', ['WINNER', 'LOSER', 'CONSOLATION', 'MAIN']);
export const bracketTypes = t.pgEnum('bracket_type', enumToPgEnum(BracketType));
export const schedulingModes = t.pgEnum('scheduling_modes', enumToPgEnum(SchedulingMode));
export const brackets = t.pgTable('brackets', {
id: t.uuid('id').primaryKey().defaultRandom(),
@ -12,6 +14,7 @@ export const brackets = t.pgTable('brackets', {
bracketType: bracketTypes('bracket_type').notNull(),
position: t.integer('position').default(1).notNull(),
isActive: t.boolean('is_active').default(true).notNull(),
scheduling_mode: schedulingModes('scheduling_modes').notNull(),
competition_id: t.uuid('competition_id').notNull(),
...timestamps
});
@ -19,7 +22,15 @@ export const brackets = t.pgTable('brackets', {
export const bracketsRelations = relations(brackets, ({ many, one }) => ({
competition: one(competitions, {
fields: [brackets.competition_id],
references: [competitions.id]
references: [competitions.id],
relationName: 'bracketCompetition'
}),
rounds: many(rounds)
}));
export type BracketInsert = typeof brackets.$inferInsert;
export type Bracket = TModelWithRelations<'brackets'>;
export type BracketWithRelations = TModelWithRelations<'brackets'>;
export type BracketAndRoundsWithMatches = typeof brackets.$inferSelect & {
rounds: RoundWithMatches[];
};

View file

@ -25,11 +25,13 @@ export const breakperiods = t.pgTable('breakperiods', {
export const breakperiodsRelations = relations(breakperiods, ({ one }) => ({
competition: one(competitions, {
fields: [breakperiods.competition_id],
references: [competitions.id]
references: [competitions.id],
relationName: 'breakPeriodCompetition'
}),
field: one(fields, {
fields: [breakperiods.field_id],
references: [fields.id]
references: [fields.id],
relationName: 'breakPeriodField'
})
}));

View file

@ -5,7 +5,7 @@ import { users } from './users';
import { breakperiods } from './breakperiods';
import { fields } from './fields';
import { teams } from './teams';
import { rounds } from './rounds';
import { brackets } from './brackets';
export const competitions = t.pgTable('competitions', {
id: t.uuid('id').primaryKey().defaultRandom(),
@ -20,12 +20,13 @@ export const competitions = t.pgTable('competitions', {
export const competitionsRelations = relations(competitions, ({ one, many }) => ({
owner: one(users, {
fields: [competitions.owner],
references: [users.id]
references: [users.id],
relationName: 'competitionOwner'
}),
breakperiods: many(breakperiods),
fields: many(fields),
teams: many(teams),
rounds: many(rounds)
brackets: many(brackets)
}));
export type Competition = typeof competitions.$inferSelect;

View file

@ -14,7 +14,8 @@ export const fields = t.pgTable('fields', {
export const fieldsRelations = relations(fields, ({ one }) => ({
competition: one(competitions, {
fields: [fields.competition_id],
references: [competitions.id]
references: [competitions.id],
relationName: 'fieldCompetition'
})
}));

View file

@ -1,12 +1,13 @@
import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../util';
import { timestamps, type TModelWithRelations } from '../util';
import { relations } from 'drizzle-orm';
import { fields } from './fields';
import { rounds } from './rounds';
import { teams } from './teams';
import { brackets } from './brackets';
import { enumToPgEnum, MatchStatus } from '../../../../types';
export const status = t.pgEnum('match_status', ['pending', 'running', 'finished', 'cancelled']);
export const status = t.pgEnum('match_status', enumToPgEnum(MatchStatus));
export const matches = t.pgTable('matches', {
id: t.uuid('id').primaryKey().defaultRandom(),
@ -23,32 +24,46 @@ export const matches = t.pgTable('matches', {
position: t.integer('position').notNull(),
table: t.integer('table').notNull(), // swis/round robin
round_id: t.uuid('round_id').notNull(),
bracket_id: t.uuid('bracket_id').notNull(),
bracket_id: t
.uuid('bracket_id')
.notNull()
.references(() => brackets.id, { onDelete: 'cascade' }),
...timestamps
});
export const matchRelations = relations(matches, ({ one }) => ({
field: one(fields, {
fields: [matches.field_id],
references: [fields.id]
references: [fields.id],
relationName: 'matchField'
}),
round: one(rounds, {
fields: [matches.round_id],
references: [rounds.id]
references: [rounds.id],
relationName: 'round'
}),
team1: one(teams, {
fields: [matches.team1_id],
references: [teams.id]
references: [teams.id],
relationName: 'matchTeam1'
}),
team2: one(teams, {
fields: [matches.team2_id],
references: [teams.id]
references: [teams.id],
relationName: 'matchTeam2'
}),
winner: one(teams, {
fields: [matches.winner_id],
references: [teams.id]
references: [teams.id],
relationName: 'matchWinner'
}),
bracket: one(brackets)
bracket: one(brackets, {
fields: [matches.bracket_id],
references: [brackets.id],
relationName: 'matchBracket'
})
}));
export type Match = typeof matches.$inferSelect;
export type MatchWithRelations = TModelWithRelations<'matches'>;

View file

@ -1,33 +1,24 @@
import { relations } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core';
import { matches } from './matches';
import { competitions } from './competitions';
import { enumToPgEnum, SchedulingMode } from '../../../../types';
import { timestamps, type TModelWithRelations } from '../util';
import { brackets } from './brackets';
export const schedulingModes = t.pgEnum('scheduling_modes', enumToPgEnum(SchedulingMode));
export const rounds = t.pgTable('rounds', {
id: t.uuid('id').primaryKey().defaultRandom(),
name: t.text('name').notNull(),
round_number: t.integer('round_number').notNull(),
nb_matches: t.integer('nb_matches').notNull(),
competition_id: t.uuid('competition_id').notNull(),
scheduling_mode: schedulingModes('scheduling_modes'),
bracket_id: t.uuid('bracket_id'),
bracket_id: t.uuid('bracket_id').references(() => brackets.id, { onDelete: 'cascade' }),
...timestamps
});
export const roundsRelations = relations(rounds, ({ many, one }) => ({
competition: one(competitions, {
fields: [rounds.competition_id],
references: [competitions.id]
}),
matches: many(matches),
bracket: one(brackets, {
fields: [rounds.bracket_id],
references: [brackets.id]
references: [brackets.id],
relationName: 'bracket'
})
}));
@ -36,3 +27,7 @@ export type Round = typeof rounds.$inferSelect;
export type RoundInsert = typeof rounds.$inferInsert;
export type RoundWithRelations = TModelWithRelations<'rounds'>;
export type RoundWithMatches = typeof rounds.$inferSelect & {
matches: (typeof matches.$inferSelect)[];
};

View file

@ -25,11 +25,13 @@ export const permissionsToRoles = t.pgTable(
export const permissionsToRolesRelations = relations(permissionsToRoles, ({ one }) => ({
permission: one(permissions, {
fields: [permissionsToRoles.permission_id],
references: [permissions.id]
references: [permissions.id],
relationName: 'permissionRole'
}),
role: one(roles, {
fields: [permissionsToRoles.role_id],
references: [roles.id]
references: [roles.id],
relationName: 'rolePermission'
})
}));
@ -53,11 +55,13 @@ export const usersToRoles = t.pgTable(
export const usersToRolesRelations = relations(usersToRoles, ({ one }) => ({
user: one(users, {
fields: [usersToRoles.user_id],
references: [users.id]
references: [users.id],
relationName: 'userRole'
}),
role: one(roles, {
fields: [usersToRoles.role_id],
references: [roles.id]
references: [roles.id],
relationName: 'roleUser'
})
}));
@ -81,10 +85,12 @@ export const usersToPermissions = t.pgTable(
export const usersToPermissionsRelations = relations(usersToPermissions, ({ one }) => ({
user: one(users, {
fields: [usersToPermissions.user_id],
references: [users.id]
references: [users.id],
relationName: 'userPermission'
}),
permission: one(permissions, {
fields: [usersToPermissions.permission_id],
references: [permissions.id]
references: [permissions.id],
relationName: 'permissionUser'
})
}));

View file

@ -13,7 +13,8 @@ export const teams = t.pgTable('teams', {
export const teamsRelations = relations(teams, ({ one }) => ({
competition: one(competitions, {
fields: [teams.competition_id],
references: [competitions.id]
references: [competitions.id],
relationName: 'teamCompetition'
})
}));

View file

@ -1,5 +1,5 @@
import * as t from 'drizzle-orm/pg-core';
import { permissionsToRoles, usersToRoles } from './schema';
import { usersToPermissions, usersToRoles } from './schema';
import { relations } from 'drizzle-orm';
import { timestamps } from '../util';
@ -12,7 +12,7 @@ export const users = t.pgTable('user', {
});
export const usersRelations = relations(users, ({ many }) => ({
permissionsToRoles: many(permissionsToRoles),
usersToPermissions: many(usersToPermissions),
usersToRoles: many(usersToRoles)
}));

View file

@ -0,0 +1,635 @@
import { db } from '../db';
import { brackets } from '../db/schema/brackets';
import { rounds } from '../db/schema/rounds';
import { matches, type Match } from '../db/schema/matches';
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
import { BaseTournamentGenerator, type SeededTeam } from './types';
import type { RoundInsert } from '../db/schema/rounds';
import { v7 } from 'uuid';
import { and, desc, eq } from 'drizzle-orm';
import { fields } from '../db/schema/fields';
import type { Team } from '../db/schema/teams';
export class DoubleEliminationGenerator extends BaseTournamentGenerator {
getMode(): SchedulingMode {
return SchedulingMode.double;
}
/**
* Generate brackets for a double elimination tournament
* Double elimination requires both winners and losers brackets
*/
async generateBrackets(): Promise<string[]> {
// Insert the winner's bracket
const [winnersBracket] = await db
.insert(brackets)
.values({
id: v7(),
name: 'Winners Bracket',
bracketType: BracketType.WINNER,
position: 1,
isActive: true,
competition_id: this.competitionId,
scheduling_mode: SchedulingMode.double
})
.returning();
// Insert the losers bracket
const [losersBracket] = await db
.insert(brackets)
.values({
id: v7(),
name: 'Losers Bracket',
bracketType: BracketType.LOSER,
position: 2,
isActive: true,
competition_id: this.competitionId,
scheduling_mode: SchedulingMode.double
})
.returning();
return [winnersBracket.id, losersBracket.id];
}
/**
* Generate rounds for a specific bracket in a double elimination tournament
*/
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
// Get the bracket to determine if it's winners or losers bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, bracketId));
if (!bracket) {
throw new Error(`Bracket not found: ${bracketId}`);
}
const isWinnersBracket = bracket.bracketType === BracketType.WINNER;
const createdRounds: RoundInsert[] = [];
// Calculate the number of rounds needed
const roundsInWinnersBracket = Math.ceil(Math.log2(this.teamsCount));
// For winners bracket
if (isWinnersBracket) {
// Create all rounds for the winners bracket
for (let i = 0; i < roundsInWinnersBracket; i++) {
const roundNumber = i + 1;
let roundName = '';
// Name rounds appropriately
if (i === roundsInWinnersBracket - 1) {
roundName = 'Winners Finals';
} else if (i === roundsInWinnersBracket - 2) {
roundName = 'Winners Semi-Finals';
} else {
roundName = `Winners Round ${roundNumber}`;
}
// Calculate matches in this round
const matchesInRound = Math.floor(this.teamsCount / Math.pow(2, i));
const roundData: RoundInsert = {
name: roundName,
round_number: roundNumber,
nb_matches: matchesInRound,
bracket_id: bracketId
};
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
createdRounds.push(insertedRound);
}
// Add a Grand Finals round for the winners bracket
const grandFinalsRound: RoundInsert = {
name: 'Grand Finals',
round_number: roundsInWinnersBracket + 1,
nb_matches: 1,
bracket_id: bracketId
};
const [insertedGrandFinals] = await db.insert(rounds).values(grandFinalsRound).returning();
createdRounds.push(insertedGrandFinals);
// Add a potential reset bracket for the Grand Finals (if loser of winners bracket wins)
const resetRound: RoundInsert = {
name: 'Grand Finals Reset',
round_number: roundsInWinnersBracket + 2,
nb_matches: 1,
bracket_id: bracketId
};
const [insertedReset] = await db.insert(rounds).values(resetRound).returning();
createdRounds.push(insertedReset);
}
// For losers bracket
else {
// Losers bracket has 2 * log2(n) - 1 rounds
const totalLosersRounds = 2 * roundsInWinnersBracket - 1;
for (let i = 0; i < totalLosersRounds; i++) {
const roundNumber = i + 1;
let roundName = '';
// Name rounds appropriately
if (i === totalLosersRounds - 1) {
roundName = 'Losers Finals';
} else if (i === totalLosersRounds - 2) {
roundName = 'Losers Semi-Finals';
} else {
roundName = `Losers Round ${roundNumber}`;
}
// Calculate matches in this round - more complex for losers bracket
// In double elimination, losers bracket structure depends on the round
let matchesInRound: number;
if (i % 2 === 0) {
// Rounds where losers from winners bracket enter
matchesInRound = Math.floor(this.teamsCount / Math.pow(2, Math.floor(i / 2) + 1));
} else {
// Rounds where losers bracket teams play each other
matchesInRound = Math.floor(this.teamsCount / Math.pow(2, Math.floor(i / 2) + 2));
}
// Ensure at least 1 match
matchesInRound = Math.max(1, matchesInRound);
const roundData: RoundInsert = {
name: roundName,
round_number: roundNumber,
nb_matches: matchesInRound,
bracket_id: bracketId
};
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
createdRounds.push(insertedRound);
}
}
return createdRounds;
}
/**
* Generate matches for the first round of a bracket
*/
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
// Get the round
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
if (!round) {
throw new Error(`Round not found: ${roundId}`);
}
if (!round.bracket_id) {
throw new Error(`Round ${roundId} has no bracket`);
}
// Get the bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
if (!bracket) {
throw new Error(`Bracket not found for round: ${roundId}`);
}
// Get a default field
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// For winners bracket first round, seed teams appropriately
if (bracket.bracketType === BracketType.WINNER && round.round_number === 1) {
// Create seeded teams
const seededTeams: SeededTeam[] = teams.map((team, index) => ({
teamId: team.id,
seed: index + 1
}));
// Generate optimal pairings for single elimination (same as first round of double elimination)
const pairings = this.createDoubleEliminationPairings(seededTeams);
// Create matches
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
return {
id: v7(),
match_number: index + 1,
field_id: defaultField.id,
team1_id: pairing.team1Id,
team2_id: pairing.team2Id || '00000000-0000-0000-0000-000000000000', // Default "bye" UUID if needed
winner_id: null,
start_date: now,
end_date: endTime,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
position: index + 1,
table: 0, // Not relevant for elimination tournaments
round_id: roundId,
bracket_id: bracket.id,
created_at: now,
updated_at: null,
deleted_at: null
};
});
// Insert matches and return them
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
return createdMatches;
}
// For losers bracket first round, this is generated based on results from winners bracket
// This would be handled separately by a specialized method
throw new Error('For rounds after the first round or losers bracket, use generateNextRound');
}
/**
* Generate the next round based on previous round results
* This is complex for double elimination as it involves tracking both winners and losers
*/
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
// Get the previous round
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
if (!previousRound) {
throw new Error(`Previous round not found: ${previousRoundId}`);
}
// Get the bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, bracketId));
if (!bracket) {
throw new Error(`Bracket not found: ${bracketId}`);
}
// Get all rounds in the current bracket
const allRounds = await db
.select()
.from(rounds)
.where(eq(rounds.bracket_id, bracketId))
.orderBy(rounds.round_number);
// Find the next round
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
if (!nextRound) {
// This could happen if we're at the final round of a bracket
return null;
}
// Get previous round's matches
const previousMatches = await db
.select()
.from(matches)
.where(eq(matches.round_id, previousRoundId))
.orderBy(matches.position);
// Check if all matches have results
const allMatchesComplete = previousMatches.every((match) => match.winner_id);
if (!allMatchesComplete) {
throw new Error(
'Cannot generate next round until all matches in the current round have winners'
);
}
// Default field
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// Handle based on bracket type
if (bracket.bracketType === BracketType.WINNER) {
// For winners bracket, winners advance to next round
const matchesToCreate: Match[] = [];
// Special case for Grand Finals
if (nextRound.name === 'Grand Finals') {
// Get winners bracket finalist
const winnersBracketFinalist = previousMatches[0].winner_id;
if (!winnersBracketFinalist) {
throw new Error('No winners bracket finalist found');
}
// Get losers bracket finalist
const losersBrackets = await db
.select()
.from(brackets)
.where(
and(
eq(brackets.competition_id, this.competitionId),
eq(brackets.bracketType, BracketType.LOSER)
)
);
if (losersBrackets.length === 0) {
throw new Error('No losers bracket found for grand finals');
}
const losersFinals = await db
.select()
.from(rounds)
.where(eq(rounds.bracket_id, losersBrackets[0].id))
.orderBy(desc(rounds.round_number))
.limit(1);
if (losersFinals.length === 0) {
throw new Error('No losers finals found');
}
const losersFinalsMatches = await db
.select()
.from(matches)
.where(eq(matches.round_id, losersFinals[0].id));
if (losersFinalsMatches.length === 0 || !losersFinalsMatches[0].winner_id) {
throw new Error('Losers finalist not determined yet');
}
const losersBracketFinalist = losersFinalsMatches[0].winner_id;
// Create grand finals match
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
matchesToCreate.push({
id: v7(),
match_number: 1,
team1_id: winnersBracketFinalist,
team2_id: losersBracketFinalist,
start_date: now,
end_date: endTime,
position: 1,
table: 0,
round_id: nextRound.id,
bracket_id: bracketId,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
});
}
// For Reset bracket (if loser's bracket winner wins grand finals)
else if (nextRound.name === 'Grand Finals Reset') {
// This would be triggered after grand finals if the loser's bracket winner wins
// Implementation depends on your reset bracket logic
}
// Normal winners bracket progression
else {
// Create next round matches by pairing winners
for (let i = 0; i < nextRound.nb_matches; i++) {
const team1Id = previousMatches[i * 2].winner_id;
const team2Id = previousMatches[i * 2 + 1].winner_id;
if (!team1Id || !team2Id) {
throw new Error('Missing team IDs');
}
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
matchesToCreate.push({
id: v7(),
match_number: i + 1,
team1_id: team1Id,
team2_id: team2Id,
start_date: now,
end_date: endTime,
position: i + 1,
table: 0,
round_id: nextRound.id,
bracket_id: bracketId,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
});
}
// Also send losers to the losers bracket
await this.sendLosersToLosersBracket(previousMatches, previousRound.round_number);
}
if (matchesToCreate.length > 0) {
await db.insert(matches).values(matchesToCreate);
}
} else if (bracket.bracketType === BracketType.LOSER) {
// Losers bracket logic is more complex and depends on the round
// In odd-numbered rounds, losers from winners bracket join
// In even-numbered rounds, losers bracket teams face each other
const matchesToCreate: Match[] = [];
// Logic varies based on the round number
if (previousRound.round_number % 2 === 1) {
// In odd rounds, winners advance to next round
for (let i = 0; i < nextRound.nb_matches; i++) {
// If there are enough matches in the previous round
if (i * 2 + 1 < previousMatches.length) {
const team1Id = previousMatches[i * 2].winner_id;
const team2Id = previousMatches[i * 2 + 1].winner_id;
if (!team1Id || !team2Id) {
throw new Error('Missing team IDs');
}
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
matchesToCreate.push({
id: v7(),
match_number: i + 1,
team1_id: team1Id,
team2_id: team2Id,
start_date: now,
end_date: endTime,
position: i + 1,
table: 0,
round_id: nextRound.id,
bracket_id: bracketId,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
});
}
}
} else {
// In even rounds, losers from winners bracket join
// This requires looking up the corresponding winners bracket matches
// Implementation would depend on your exact double elimination structure
// This is a complex part that varies by tournament design
}
if (matchesToCreate.length > 0) {
await db.insert(matches).values(matchesToCreate);
}
}
return nextRound;
}
/**
* Send losers from winners bracket to losers bracket
* This is a key part of double elimination
*/
private async sendLosersToLosersBracket(
winnersBracketMatches: any[],
winnersRoundNumber: number
): Promise<void> {
// Get the losers bracket
const [losersBracket] = await db
.select()
.from(brackets)
.where(
and(
eq(brackets.competition_id, this.competitionId),
eq(brackets.bracketType, BracketType.LOSER)
)
);
if (!losersBracket) {
throw new Error('No losers bracket found');
}
// Find the appropriate losers bracket round
// In standard double elimination, losers from winners round N go to losers round 2N-1
const targetLosersRound = winnersRoundNumber * 2 - 1;
const [losersRound] = await db
.select()
.from(rounds)
.where(
and(eq(rounds.bracket_id, losersBracket.id), eq(rounds.round_number, targetLosersRound))
);
if (!losersRound) {
throw new Error(`No losers round found for winners round ${winnersRoundNumber}`);
}
// Get a default field
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// Create matches in the losers bracket with the losers from winners bracket
const matchesToCreate: Match[] = [];
let matchCount = 0;
// For each match in the winners bracket, get the loser
for (let i = 0; i < winnersBracketMatches.length; i++) {
const match = winnersBracketMatches[i];
if (!match.winner_id) continue;
// Determine the loser
const loserId = match.winner_id === match.team1_id ? match.team2_id : match.team1_id;
// In first losers round, each loser gets their own match (often with a bye)
if (targetLosersRound === 1) {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
matchesToCreate.push({
id: v7(),
match_number: matchCount + 1,
team1_id: loserId,
team2_id: '00000000-0000-0000-0000-000000000000', // Initial bye or determined by bracket structure
start_date: now,
end_date: endTime,
position: matchCount + 1,
table: 0,
round_id: losersRound.id,
bracket_id: losersBracket.id,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
});
matchCount++;
}
// In later rounds, losers typically play against survivors from previous losers rounds
// This would be handled in the main generateNextRound method for the losers bracket
}
if (matchesToCreate.length > 0) {
await db.insert(matches).values(matchesToCreate);
}
}
/**
* Create optimal pairings for the first round of double elimination
* Uses standard tournament seeding to ensure balanced bracket
*/
private createDoubleEliminationPairings(
seededTeams: SeededTeam[]
): { team1Id: string; team2Id: string | null }[] {
// For first round, use same seeding as single elimination
const totalTeamsInFullBracket = Math.pow(2, this.calculateRoundsNeeded());
const pairings: { team1Id: string; team2Id: string | null }[] = [];
// Sort teams by seed
const sortedTeams = [...seededTeams].sort((a, b) => a.seed - b.seed);
// If we need to give byes, calculate them
const teamsWithByes = sortedTeams.slice(0, 2 * sortedTeams.length - totalTeamsInFullBracket);
const teamsWithoutByes = sortedTeams.slice(teamsWithByes.length);
// Add matches for teams with direct advancement (byes)
for (const team of teamsWithByes) {
pairings.push({
team1Id: team.teamId,
team2Id: null // Bye
});
}
// Create standard bracket pairings for remaining teams
for (let i = 0; i < teamsWithoutByes.length; i += 2) {
pairings.push({
team1Id: teamsWithoutByes[i].teamId,
team2Id: teamsWithoutByes[i + 1].teamId
});
}
return pairings;
}
}

View file

@ -0,0 +1,238 @@
import { db } from '../db';
import { brackets } from '../db/schema/brackets';
import { rounds } from '../db/schema/rounds';
import { matches, type Match } from '../db/schema/matches';
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
import { BaseTournamentGenerator } from './types';
import type { RoundInsert } from '../db/schema/rounds';
import { v7 } from 'uuid';
import { eq } from 'drizzle-orm';
import { fields } from '../db/schema/fields';
import type { Team } from '../db/schema/teams';
export class DoubleRoundRobinGenerator extends BaseTournamentGenerator {
getMode(): SchedulingMode {
return SchedulingMode.double_round_robin;
}
/**
* Generate a single main bracket for double round robin
*/
async generateBrackets(): Promise<string[]> {
const [bracket] = await db
.insert(brackets)
.values({
id: v7(),
name: 'Main Bracket',
bracketType: BracketType.MAIN,
position: 1,
isActive: true,
competition_id: this.competitionId,
scheduling_mode: SchedulingMode.double_round_robin
})
.returning();
return [bracket.id];
}
/**
* Generate all rounds for a double round robin tournament
* In double round robin, each team plays against every other team twice
* (once home, once away)
*/
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
// Number of rounds needed = 2 * (n-1) where n is the number of teams
const totalRounds = 2 * (this.teamsCount - 1);
const createdRounds: RoundInsert[] = [];
for (let i = 0; i < totalRounds; i++) {
const roundNumber = i + 1;
const roundName = `Round ${roundNumber}`;
// Each round has floor(n/2) matches
const matchesInRound = Math.floor(this.teamsCount / 2);
const roundData: RoundInsert = {
name: roundName,
round_number: roundNumber,
nb_matches: matchesInRound,
bracket_id: bracketId
};
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
createdRounds.push(insertedRound);
}
return createdRounds;
}
/**
* Generate matches for a double round robin tournament
* This extends the round robin algorithm to create two sets of matches
* with home/away teams reversed in the second half
*/
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
// Get the round
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
if (!round) {
throw new Error(`Round not found: ${roundId}`);
}
if (!round.bracket_id) {
throw new Error(`Round ${roundId} has no bracket`);
}
// Get the bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
if (!bracket) {
throw new Error(`Bracket not found for round: ${roundId}`);
}
// Default field (could be enhanced to assign different fields)
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// Generate pairings using the circle method
const singleRoundRobinRounds = this.teamsCount - 1;
const isSecondHalf = round.round_number > singleRoundRobinRounds;
// For second half, we need to flip home/away teams
const effectiveRoundNumber = isSecondHalf
? round.round_number - singleRoundRobinRounds
: round.round_number;
// Generate pairings for this round
const pairings = this.generateRoundRobinPairings(teams, effectiveRoundNumber);
// Create match objects
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
// For the second half of matches, swap team1 and team2 to reverse home/away
const [team1Id, team2Id] = isSecondHalf
? [pairing.team2Id, pairing.team1Id]
: [pairing.team1Id, pairing.team2Id];
return {
id: v7(),
match_number: index + 1,
team1_id: team1Id,
team2_id: team2Id,
start_date: now,
end_date: endTime,
position: index + 1,
table: index + 1, // Table assignment
round_id: roundId,
bracket_id: bracket.id,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
};
});
// Insert matches and return them
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
return createdMatches;
}
/**
* In double round robin, there's no "next round" generation based on results
* All rounds are created at the beginning
*/
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
// Get the previous round
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
if (!previousRound) {
throw new Error(`Previous round not found: ${previousRoundId}`);
}
// Get all rounds in the bracket
const allRounds = await db
.select()
.from(rounds)
.where(eq(rounds.bracket_id, bracketId))
.orderBy(rounds.round_number);
// Check if this was the final round
if (previousRound.round_number === allRounds.length) {
return null; // Tournament is complete
}
// Find the next round
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
if (!nextRound) {
throw new Error(`Next round not found for round ${previousRound.round_number}`);
}
return nextRound;
}
/**
* Generate pairings for a round robin tournament using the circle method
* In this method, one team stays fixed (team at index 0) and others rotate clockwise
*/
private generateRoundRobinPairings(teams: Team[], roundNumber: number) {
// If odd number of teams, add a "bye" team
if (teams.length % 2 !== 0) {
teams.push({
id: '00000000-0000-0000-0000-000000000000',
name: '',
created_at: new Date(Date.now()),
updated_at: null,
deleted_at: null,
competition_id: ''
}); // Bye team ID
}
const n = teams.length;
const pairings: { team1Id: string; team2Id: string }[] = [];
// Create a copy of the teams array that we can manipulate
const teamsForRound = [...teams];
// Rotate the teams (except the first one) based on the round number
// For round 1, use teams as is
// For subsequent rounds, rotate teams[1...n-1] clockwise
if (roundNumber > 1) {
// Apply rotation (roundNumber - 1) times
for (let i = 0; i < roundNumber - 1; i++) {
const lastTeam = teamsForRound.pop()!;
teamsForRound.splice(1, 0, lastTeam);
}
}
// Create pairings for this round
for (let i = 0; i < n / 2; i++) {
// Match teams[i] with teams[n-1-i]
pairings.push({
team1Id: teamsForRound[i].id,
team2Id: teamsForRound[n - 1 - i].id
});
}
// Filter out pairings with the bye team
return pairings.filter(
(pairing) =>
pairing.team1Id !== '00000000-0000-0000-0000-000000000000' &&
pairing.team2Id !== '00000000-0000-0000-0000-000000000000'
);
}
}

View file

@ -0,0 +1,33 @@
import { SingleEliminationGenerator } from './single-elimination';
import { DoubleEliminationGenerator } from './double-elimination';
import { SwissGenerator } from './swiss';
import { RoundRobinGenerator } from './round-robin';
import { DoubleRoundRobinGenerator } from './double-round-robin';
import { SchedulingMode } from '../../../types';
import type { TournamentGenerator } from './types';
/**
* Factory function to create the appropriate tournament generator based on the scheduling mode
*/
export function createTournamentGenerator(
mode: SchedulingMode,
competitionId: string,
teamsCount: number
): TournamentGenerator {
switch (mode) {
case SchedulingMode.single:
return new SingleEliminationGenerator(competitionId, teamsCount);
case SchedulingMode.double:
return new DoubleEliminationGenerator(competitionId, teamsCount);
case SchedulingMode.swiss:
return new SwissGenerator(competitionId, teamsCount);
case SchedulingMode.round_robin:
return new RoundRobinGenerator(competitionId, teamsCount);
case SchedulingMode.double_round_robin:
return new DoubleRoundRobinGenerator(competitionId, teamsCount);
default:
throw new Error(`Unsupported scheduling mode: ${mode}`);
}
}
export * from './types';

View file

@ -0,0 +1,222 @@
import { db } from '../db';
import { brackets, schedulingModes } from '../db/schema/brackets';
import { rounds } from '../db/schema/rounds';
import { matches, type Match } from '../db/schema/matches';
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
import { BaseTournamentGenerator } from './types';
import type { RoundInsert } from '../db/schema/rounds';
import { v7 } from 'uuid';
import { fields } from '../db/schema/fields';
import { eq } from 'drizzle-orm';
import type { Team } from '../db/schema/teams';
export class RoundRobinGenerator extends BaseTournamentGenerator {
getMode(): SchedulingMode {
return SchedulingMode.round_robin;
}
/**
* Generate a single main bracket for round robin
*/
async generateBrackets(): Promise<string[]> {
const [bracket] = await db
.insert(brackets)
.values({
id: v7(),
name: 'Main Bracket',
bracketType: BracketType.MAIN,
position: 1,
isActive: true,
competition_id: this.competitionId,
scheduling_mode: SchedulingMode.round_robin
})
.returning();
return [bracket.id];
}
/**
* Generate all rounds for a round robin tournament
* In round robin, each team plays against every other team once
*/
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
// Number of rounds needed = n-1 where n is the number of teams
const totalRounds = this.teamsCount - 1;
const createdRounds: RoundInsert[] = [];
for (let i = 0; i < totalRounds; i++) {
const roundNumber = i + 1;
const roundName = `Round ${roundNumber}`;
// Each round has floor(n/2) matches
const matchesInRound = Math.floor(this.teamsCount / 2);
const roundData: RoundInsert = {
name: roundName,
round_number: roundNumber,
nb_matches: matchesInRound,
bracket_id: bracketId
};
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
createdRounds.push(insertedRound);
}
return createdRounds;
}
/**
* Generate matches for a round robin tournament using the circle method
* This creates optimal pairings for each round
*/
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
// Get the round
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
if (!round) {
throw new Error(`Round not found: ${roundId}`);
}
if (!round.bracket_id) {
throw new Error(`Round ${roundId} has no bracket`);
}
// Get the bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
if (!bracket) {
throw new Error(`Bracket not found for round: ${roundId}`);
}
// Default field (could be enhanced to assign different fields)
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// Generate pairings using the circle method
const pairings = this.generateRoundRobinPairings(teams, round.round_number);
// Create match objects
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1); // Default 1-hour match
return {
id: v7(),
match_number: index + 1,
team1_id: pairing.team1Id,
team2_id: pairing.team2Id,
start_date: now,
end_date: endTime,
position: index + 1,
table: index + 1, // Table assignment (could be enhanced)
round_id: roundId,
bracket_id: bracket.id,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
};
});
// Insert matches and return them
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
return createdMatches;
}
/**
* In round robin, there's no "next round" generation based on results
* All rounds are created at the beginning
*/
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
// Get the previous round
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
if (!previousRound) {
throw new Error(`Previous round not found: ${previousRoundId}`);
}
// Get all rounds in the bracket
const allRounds = await db
.select()
.from(rounds)
.where(eq(rounds.bracket_id, bracketId))
.orderBy(rounds.round_number);
// Check if this was the final round
if (previousRound.round_number === allRounds.length) {
return null; // Tournament is complete
}
// Find the next round
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
if (!nextRound) {
throw new Error(`Next round not found for round ${previousRound.round_number}`);
}
return nextRound;
}
/**
* Generate pairings for a round robin tournament using the circle method
* In this method, one team stays fixed (team at index 0) and others rotate clockwise
*/
private generateRoundRobinPairings(teams: Team[], roundNumber: number) {
// If odd number of teams, add a "bye" team
if (teams.length % 2 !== 0) {
teams.push({
id: '00000000-0000-0000-0000-000000000000',
name: '',
created_at: new Date(Date.now()),
updated_at: null,
deleted_at: null,
competition_id: ''
}); // Bye team ID
}
const n = teams.length;
const pairings: { team1Id: string; team2Id: string }[] = [];
// Create a copy of the teams array that we can manipulate
const teamsForRound = [...teams];
// Rotate the teams (except the first one) based on the round number
// For round 1, use teams as is
// For subsequent rounds, rotate teams[1...n-1] clockwise
if (roundNumber > 1) {
// Apply rotation (roundNumber - 1) times
for (let i = 0; i < roundNumber - 1; i++) {
const lastTeam = teamsForRound.pop()!;
teamsForRound.splice(1, 0, lastTeam);
}
}
// Create pairings for this round
for (let i = 0; i < n / 2; i++) {
// Match teams[i] with teams[n-1-i]
pairings.push({
team1Id: teamsForRound[i].id,
team2Id: teamsForRound[n - 1 - i].id
});
}
// Filter out pairings with the bye team
return pairings.filter(
(pairing) =>
pairing.team1Id !== '00000000-0000-0000-0000-000000000000' &&
pairing.team2Id !== '00000000-0000-0000-0000-000000000000'
);
}
}

View file

@ -0,0 +1,295 @@
import { db } from '../db';
import { brackets } from '../db/schema/brackets';
import { rounds } from '../db/schema/rounds';
import { matches, type Match } from '../db/schema/matches';
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
import { BaseTournamentGenerator, type SeededTeam } from './types';
import type { RoundInsert } from '../db/schema/rounds';
import { fields } from '../db/schema/fields';
import { v7 } from 'uuid';
import { eq } from 'drizzle-orm';
import type { Team } from '../db/schema/teams';
export class SingleEliminationGenerator extends BaseTournamentGenerator {
getMode(): SchedulingMode {
return SchedulingMode.single;
}
/**
* Generate a single bracket for the single elimination tournament
*/
async generateBrackets(): Promise<string[]> {
// Insert the winner's bracket
const [bracket] = await db
.insert(brackets)
.values({
name: 'Winner Bracket',
bracketType: BracketType.WINNER,
position: 1,
isActive: true,
competition_id: this.competitionId,
scheduling_mode: SchedulingMode.single
})
.returning();
return [bracket.id];
}
/**
* Generate all rounds needed for a single elimination tournament
*/
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
const totalRounds = this.calculateRoundsNeeded();
const createdRounds: RoundInsert[] = [];
for (let i = 0; i < totalRounds; i++) {
const roundNumber = i + 1;
let roundName = '';
// Name the rounds appropriately
if (i === totalRounds - 1) {
roundName = 'Final';
} else if (i === totalRounds - 2) {
roundName = 'Semi-Finals';
} else if (i === totalRounds - 3) {
roundName = 'Quarter-Finals';
} else {
roundName = `Round ${roundNumber}`;
}
// Calculate number of matches in this round
const matchesInRound = Math.floor(this.teamsCount / Math.pow(2, i));
const roundData: RoundInsert = {
name: roundName,
round_number: roundNumber,
nb_matches: matchesInRound,
bracket_id: bracketId
};
// Insert the round
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
createdRounds.push(insertedRound);
}
return createdRounds;
}
/**
* Generate matches for the first round of a single elimination tournament
* For subsequent rounds, use generateNextRound
*/
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
// Get the round
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
if (!round) {
throw new Error(`Round not found: ${roundId}`);
}
if (!round.bracket_id) {
throw new Error(`Round has no bracket: ${roundId}`);
}
// Get the bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
if (!bracket) {
throw new Error(`Bracket not found for round: ${roundId}`);
}
// Seed the teams (this is a simple seeding, could be enhanced)
const seededTeams: SeededTeam[] = teams.map((team, index) => ({
teamId: team.id,
seed: index + 1
}));
// Distribute teams according to standard tournament seeding
const pairings = this.createSingleEliminationPairings(seededTeams, round.round_number);
// Default field (could be enhanced to assign different fields)
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// Create match objects
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1); // Default 1-hour match
return {
id: v7(),
match_number: index + 1,
team1_id: pairing.team1Id,
team2_id: pairing.team2Id || '00000000-0000-0000-0000-000000000000', // Default "bye" UUID if needed
start_date: now,
end_date: endTime,
position: index + 1,
table: 0, // Not relevant for elimination tournaments
round_id: roundId,
bracket_id: bracket.id,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
};
});
// Insert matches and return them
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
return createdMatches;
}
/**
* Generate the next round based on the results of a previous round
*/
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
// Get the previous round
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
if (!previousRound) {
throw new Error(`Previous round not found: ${previousRoundId}`);
}
// Check if this was the final round
if (previousRound.nb_matches === 1) {
return null; // Tournament is complete
}
// Get the previous round's matches
const previousMatches = await db
.select()
.from(matches)
.where(eq(matches.round_id, previousRoundId))
.orderBy(matches.position);
// Check if all matches have winners
const allMatchesComplete = previousMatches.every((match) => match.winner_id);
if (!allMatchesComplete) {
throw new Error(
'Cannot generate next round until all matches in the current round have winners'
);
}
// Create the next round
const nextRoundNumber = previousRound.round_number + 1;
const nextRoundMatchCount = Math.floor(previousRound.nb_matches / 2);
let roundName = '';
if (nextRoundMatchCount === 1) {
roundName = 'Final';
} else if (nextRoundMatchCount === 2) {
roundName = 'Semi-Finals';
} else if (nextRoundMatchCount === 4) {
roundName = 'Quarter-Finals';
} else {
roundName = `Round ${nextRoundNumber}`;
}
const nextRoundData: RoundInsert = {
name: roundName,
round_number: nextRoundNumber,
nb_matches: nextRoundMatchCount,
bracket_id: bracketId
};
// Insert the next round
const [nextRound] = await db.insert(rounds).values(nextRoundData).returning();
// Create matches for the next round
const nextRoundMatches: Match[] = [];
// Create pairings for the next round based on winners
for (let i = 0; i < nextRoundMatchCount; i++) {
const team1Id = previousMatches[i * 2].winner_id;
const team2Id = previousMatches[i * 2 + 1].winner_id;
if (!team1Id || !team2Id) {
throw new Error('Missing winner ID for matches');
}
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
// Get the same field from previous match or default
const field_id = previousMatches[i].field_id;
nextRoundMatches.push({
id: v7(),
match_number: i + 1,
team1_id: team1Id,
team2_id: team2Id,
start_date: now,
end_date: endTime,
position: i + 1,
table: 0,
round_id: nextRound.id,
bracket_id: bracketId,
field_id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
});
}
// Insert the matches for the next round
await db.insert(matches).values(nextRoundMatches);
return nextRound;
}
/**
* Creates optimal pairings for a single elimination tournament
* Uses the standard tournament bracket seeding algorithm
*/
private createSingleEliminationPairings(
seededTeams: SeededTeam[],
roundNumber: number
): { team1Id: string; team2Id: string | null }[] {
// Handle first round with proper seeding
if (roundNumber === 1) {
const totalTeamsInFullBracket = seededTeams.length;
const pairings: { team1Id: string; team2Id: string | null }[] = [];
const nextPowerOfTwo = 1 << Math.ceil(Math.log2(totalTeamsInFullBracket));
// Sort teams by seed
const sortedTeams = [...seededTeams].sort((a, b) => a.seed - b.seed);
if (sortedTeams.length % 2 !== 0) {
sortedTeams.push({
teamId: '00000000-0000-0000-0000-000000000000',
seed: sortedTeams.length + 1
});
}
for (let i = 0; i < nextPowerOfTwo; i += 2) {
pairings.push({
team1Id: sortedTeams[i].teamId,
team2Id: sortedTeams[i + 1].teamId
});
}
return pairings;
}
// For other rounds, this should not be called directly
// Use generateNextRound instead
throw new Error('Use generateNextRound for rounds after the first round');
}
}

View file

@ -0,0 +1,472 @@
import { db } from '../db';
import { brackets } from '../db/schema/brackets';
import { rounds } from '../db/schema/rounds';
import { matches, type Match } from '../db/schema/matches';
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
import { BaseTournamentGenerator } from './types';
import type { RoundInsert } from '../db/schema/rounds';
import { fields } from '../db/schema/fields';
import { v7 } from 'uuid';
import { and, eq } from 'drizzle-orm';
import { teams, type Team } from '../db/schema/teams';
export class SwissGenerator extends BaseTournamentGenerator {
getMode(): SchedulingMode {
return SchedulingMode.swiss;
}
/**
* Generate a single main bracket for Swiss tournament
*/
async generateBrackets(): Promise<string[]> {
const [bracket] = await db
.insert(brackets)
.values({
name: 'Swiss Bracket',
bracketType: BracketType.MAIN,
position: 1,
isActive: true,
competition_id: this.competitionId,
scheduling_mode: SchedulingMode.swiss
})
.returning();
return [bracket.id];
}
/**
* Generate rounds for a Swiss tournament
* In Swiss, the number of rounds is typically log2(n), where n is the number of teams
* This ensures that a single undefeated team will emerge
*/
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
// Recommended number of rounds is log2(n) rounded up
const recommendedRounds = Math.ceil(Math.log2(this.teamsCount));
const totalRounds = Math.min(recommendedRounds, this.teamsCount - 1);
const createdRounds: RoundInsert[] = [];
for (let i = 0; i < totalRounds; i++) {
const roundNumber = i + 1;
const roundName = `Round ${roundNumber}`;
// Each round has floor(n/2) matches
const matchesInRound = Math.floor(this.teamsCount / 2);
const roundData: RoundInsert = {
name: roundName,
round_number: roundNumber,
nb_matches: matchesInRound,
bracket_id: bracketId
};
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
createdRounds.push(insertedRound);
}
return createdRounds;
}
/**
* Generate matches for a Swiss tournament
* First round is random or seeded pairings
* Subsequent rounds are handled by generateNextRound
*/
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
// Get the round
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
if (!round) {
throw new Error(`Round not found: ${roundId}`);
}
if (!round.bracket_id) {
throw new Error(`Round ${roundId} has no bracket`);
}
// Get the bracket
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
if (!bracket) {
throw new Error(`Bracket not found for round: ${roundId}`);
}
// Default field (could be enhanced to assign different fields)
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// For first round, generate random pairings
// In a real implementation, this could be seeded pairings
const pairings = this.generateFirstRoundPairings(teams);
// Create match objects
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1); // Default 1-hour match
return {
id: v7(),
match_number: index + 1,
team1_id: pairing.team1.id,
team2_id: pairing.team2?.id || '00000000-0000-0000-0000-000000000000', // Default "bye" UUID if needed
start_date: now,
end_date: endTime,
position: index + 1,
table: index + 1, // Table assignment
round_id: roundId,
bracket_id: bracket.id,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
};
});
// Insert matches and return them
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
return createdMatches;
}
/**
* Generate the next round based on current standings
* This is the core of the Swiss system - pair teams with similar records
*/
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
// Get the previous round
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
if (!previousRound) {
throw new Error(`Previous round not found: ${previousRoundId}`);
}
// Get all rounds in the bracket
const allRounds = await db
.select()
.from(rounds)
.where(eq(rounds.bracket_id, bracketId))
.orderBy(rounds.round_number);
// Check if this was the final round
if (previousRound.round_number === allRounds.length) {
return null; // Tournament is complete
}
// Find the next round
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
if (!nextRound) {
throw new Error(`Next round not found for round ${previousRound.round_number}`);
}
// Calculate standings to generate pairings
const standings = await this.calculateStandings(bracketId);
// Generate pairings based on standings
const pairings = this.generateSwissPairings(standings);
// Default field
const [defaultField] = await db
.select()
.from(fields)
.where(eq(fields.competition_id, this.competitionId))
.limit(1);
if (!defaultField) {
throw new Error('No field available for matches');
}
// Create matches for the next round
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
const now = new Date();
const endTime = new Date(now);
endTime.setHours(endTime.getHours() + 1);
return {
id: v7(),
match_number: index + 1,
team1_id: pairing.team1Id,
team2_id: pairing.team2Id || '00000000-0000-0000-0000-000000000000',
start_date: now,
end_date: endTime,
position: index + 1,
table: index + 1,
round_id: nextRound.id,
bracket_id: bracketId,
field_id: defaultField.id,
score1: 0,
score2: 0,
status: MatchStatus.PENDING,
winner_id: null,
created_at: now,
updated_at: null,
deleted_at: null
};
});
// Insert the matches for the next round
await db.insert(matches).values(matchesToCreate);
return nextRound;
}
/**
* Generate pairings for the first round
* In Swiss, the first round can be random or seeded
*/
private generateFirstRoundPairings(teams: Team[]): { team1: Team; team2: Team | null }[] {
const pairings: { team1: Team; team2: Team | null }[] = [];
// Handle odd number of teams
if (teams.length % 2 !== 0) {
// Give the bye to a random team
const byeTeamIndex = Math.floor(Math.random() * teams.length);
const byeTeam = teams.splice(byeTeamIndex, 1)[0];
pairings.push({ team1: byeTeam, team2: null });
}
// Shuffle the remaining teams for random pairings
// In a real system, this could be seeded based on team rankings
this.shuffleArray(teams);
// Create pairings
for (let i = 0; i < teams.length; i += 2) {
pairings.push({
team1: teams[i],
team2: teams[i + 1]
});
}
return pairings;
}
/**
* Calculate current standings for all teams
* This is a key component for Swiss pairings
*/
private async calculateStandings(bracketId: string): Promise<TeamStanding[]> {
// Get all completed matches in the bracket
const completedMatches = await db
.select()
.from(matches)
.where(and(eq(matches.bracket_id, bracketId), eq(matches.status, MatchStatus.FINISHED)));
// Get all teams in the competition
const teamList = await db
.select()
.from(teams)
.where(eq(teams.competition_id, this.competitionId));
// Calculate standings
const standings: Record<string, TeamStanding> = {};
// Initialize standings for all teams
teamList.forEach((team) => {
standings[team.id] = {
teamId: team.id,
wins: 0,
losses: 0,
draws: 0,
points: 0,
opponentWinPercentage: 0, // For tiebreakers
previousOpponents: []
};
});
// Update standings based on match results
completedMatches.forEach((match) => {
const team1 = standings[match.team1_id];
const team2 = standings[match.team2_id];
// Record that these teams played each other
if (team1 && team2) {
team1.previousOpponents.push(match.team2_id);
team2.previousOpponents.push(match.team1_id);
}
// If there's a winner, update win/loss records
if (match.winner_id) {
if (match.winner_id === match.team1_id) {
if (team1) {
team1.wins++;
team1.points += 3; // Typical scoring: 3 points for a win
}
if (team2) {
team2.losses++;
}
} else {
if (team2) {
team2.wins++;
team2.points += 3;
}
if (team1) {
team1.losses++;
}
}
} else if (match.score1 === match.score2) {
// Draw
if (team1) {
team1.draws++;
team1.points += 1; // Typical scoring: 1 point for a draw
}
if (team2) {
team2.draws++;
team2.points += 1;
}
}
});
// Calculate opponent win percentage (Buchholz tiebreaker)
Object.values(standings).forEach((standing) => {
if (standing.previousOpponents.length > 0) {
const opponentWins = standing.previousOpponents.reduce((sum, opponentId) => {
return sum + (standings[opponentId]?.wins || 0);
}, 0);
const opponentMatches = standing.previousOpponents.reduce((sum, opponentId) => {
const opponent = standings[opponentId];
return sum + (opponent ? opponent.wins + opponent.losses + opponent.draws : 0);
}, 0);
standing.opponentWinPercentage = opponentMatches > 0 ? opponentWins / opponentMatches : 0;
}
});
return Object.values(standings);
}
/**
* Generate Swiss pairings based on current standings
* Pairs teams with similar records while avoiding rematches
*/
private generateSwissPairings(
standings: TeamStanding[]
): { team1Id: string; team2Id: string | null }[] {
// Sort standings by points (descending), then by tiebreaker
const sortedStandings = [...standings].sort((a, b) => {
if (a.points !== b.points) {
return b.points - a.points; // Sort by points (descending)
}
return b.opponentWinPercentage - a.opponentWinPercentage; // Then by opponent win %
});
const pairings: { team1Id: string; team2Id: string | null }[] = [];
const unpairedTeams = [...sortedStandings];
// Handle odd number of teams
if (unpairedTeams.length % 2 !== 0) {
// Find the lowest-ranked team without a bye yet
// In a real implementation, you would track byes across rounds
const byeTeamIndex = unpairedTeams.length - 1;
const byeTeam = unpairedTeams.splice(byeTeamIndex, 1)[0];
pairings.push({ team1Id: byeTeam.teamId, team2Id: null });
}
// Group teams by points
const teamsByPoints: Record<number, TeamStanding[]> = {};
unpairedTeams.forEach((team) => {
if (!teamsByPoints[team.points]) {
teamsByPoints[team.points] = [];
}
teamsByPoints[team.points].push(team);
});
// Sort point groups in descending order
const pointGroups = Object.keys(teamsByPoints)
.map(Number)
.sort((a, b) => b - a);
// Try to pair within same point group first
for (const points of pointGroups) {
const teamsInGroup = teamsByPoints[points];
// For each team in this group
while (teamsInGroup.length > 0) {
const team1 = teamsInGroup.shift()!;
let paired = false;
// Try to find a valid opponent in the same group
for (let i = 0; i < teamsInGroup.length; i++) {
const team2 = teamsInGroup[i];
// Check if these teams have already played each other
if (!team1.previousOpponents.includes(team2.teamId)) {
// Valid pairing found
teamsInGroup.splice(i, 1);
pairings.push({ team1Id: team1.teamId, team2Id: team2.teamId });
paired = true;
break;
}
}
// If no valid pairing found in the same group
if (!paired) {
// Put this team back and we'll handle cross-group pairings later
teamsInGroup.unshift(team1);
break;
}
}
}
// Handle remaining unpaired teams (cross-group pairings)
const remainingTeams = pointGroups.flatMap((points) => teamsByPoints[points]);
while (remainingTeams.length > 0) {
const team1 = remainingTeams.shift()!;
// Find the best opponent that hasn't already played against team1
let bestOpponentIndex = -1;
for (let i = 0; i < remainingTeams.length; i++) {
if (!team1.previousOpponents.includes(remainingTeams[i].teamId)) {
bestOpponentIndex = i;
break;
}
}
// If no valid opponent is found, this shouldn't happen with proper implementation
// In a real system, you might relax constraints or create a "forced" pairing
if (bestOpponentIndex === -1) {
bestOpponentIndex = 0; // Force a rematch as last resort
}
const team2 = remainingTeams.splice(bestOpponentIndex, 1)[0];
pairings.push({ team1Id: team1.teamId, team2Id: team2.teamId });
}
return pairings;
}
/**
* Utility function to shuffle an array (Fisher-Yates algorithm)
*/
private shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
}
/**
* Interface representing a team's standing in a Swiss tournament
*/
interface TeamStanding {
teamId: string;
wins: number;
losses: number;
draws: number;
points: number;
opponentWinPercentage: number;
previousOpponents: string[];
}

View file

@ -0,0 +1,97 @@
import { BracketType, SchedulingMode } from '@/types';
import type { RoundInsert } from '../db/schema/rounds';
import type { Match } from '../db/schema/matches';
import type { Team } from '../db/schema/teams';
/**
* Interface for generating tournament structures
*/
export interface TournamentGenerator {
/**
* Get the scheduling mode this generator implements
*/
getMode(): SchedulingMode;
/**
* Generate the bracket structure needed for this tournament type
* Returns an array of bracket IDs that were created
*/
generateBrackets(): Promise<string[]>;
/**
* Generate all rounds for a specific bracket
* @param bracketId The bracket to generate rounds for
*/
generateRounds(bracketId: string): Promise<RoundInsert[]>;
/**
* Generate all matches for a specific round
* @param roundId The round to generate matches for
* @param teams Array of team IDs participating in the tournament
*/
generateMatches(roundId: string, teams: Team[]): Promise<Match[]>;
/**
* Generate the next round based on previous round results
* @param previousRoundId ID of the previous round
* @param bracketId ID of the bracket
*/
generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null>;
}
/**
* Represents a team seeded in the tournament
*/
export interface SeededTeam {
teamId: string;
seed: number;
}
/**
* Configuration for bracket generation
*/
export interface BracketConfig {
name: string;
bracketType: BracketType;
position: number;
isActive: boolean;
}
/**
* Base class for all tournament generators
*/
export abstract class BaseTournamentGenerator implements TournamentGenerator {
protected competitionId: string;
protected teamsCount: number;
constructor(competitionId: string, teamsCount: number) {
this.competitionId = competitionId;
this.teamsCount = teamsCount;
}
abstract getMode(): SchedulingMode;
abstract generateBrackets(): Promise<string[]>;
abstract generateRounds(bracketId: string): Promise<RoundInsert[]>;
abstract generateMatches(roundId: string, teams: Team[]): Promise<Match[]>;
abstract generateNextRound(
previousRoundId: string,
bracketId: string
): Promise<RoundInsert | null>;
/**
* Calculate the number of rounds needed for a single elimination tournament
*/
protected calculateRoundsNeeded(): number {
return Math.ceil(Math.log2(this.teamsCount));
}
/**
* Calculate the number of matches in the first round
*/
protected calculateFirstRoundMatches(): number {
const roundsNeeded = this.calculateRoundsNeeded();
const fullBracketTeams = Math.pow(2, roundsNeeded);
const byes = fullBracketTeams - this.teamsCount;
return this.teamsCount - byes;
}
}