starting to have a bracket system that is previewable
This commit is contained in:
parent
62127cc5e4
commit
82ecf80068
82 changed files with 3461 additions and 637 deletions
|
@ -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 -->
|
||||
|
|
26
src/lib/components/brackets/bracket.svelte
Normal file
26
src/lib/components/brackets/bracket.svelte
Normal 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>
|
|
@ -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>
|
173
src/lib/components/brackets/match.svelte
Normal file
173
src/lib/components/brackets/match.svelte
Normal 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>
|
40
src/lib/components/brackets/round.svelte
Normal file
40
src/lib/components/brackets/round.svelte
Normal 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>
|
0
src/lib/components/brackets/types/double.svelte
Normal file
0
src/lib/components/brackets/types/double.svelte
Normal file
0
src/lib/components/brackets/types/round_robin.svelte
Normal file
0
src/lib/components/brackets/types/round_robin.svelte
Normal file
52
src/lib/components/brackets/types/single.svelte
Normal file
52
src/lib/components/brackets/types/single.svelte
Normal 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>
|
0
src/lib/components/brackets/types/swiss.svelte
Normal file
0
src/lib/components/brackets/types/swiss.svelte
Normal 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;
|
|
@ -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>
|
17
src/lib/components/theme-toggle.svelte
Normal file
17
src/lib/components/theme-toggle.svelte
Normal 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>
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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?.()}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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?.()}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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 });
|
||||
|
|
51
src/lib/server/db/queries/brackets.ts
Normal file
51
src/lib/server/db/queries/brackets.ts
Normal 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
|
||||
});
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
})
|
||||
}));
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
})
|
||||
}));
|
||||
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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)[];
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
})
|
||||
}));
|
||||
|
|
|
@ -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'
|
||||
})
|
||||
}));
|
||||
|
||||
|
|
|
@ -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)
|
||||
}));
|
||||
|
||||
|
|
635
src/lib/server/tournament/double-elimination.ts
Normal file
635
src/lib/server/tournament/double-elimination.ts
Normal 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;
|
||||
}
|
||||
}
|
238
src/lib/server/tournament/double-round-robin.ts
Normal file
238
src/lib/server/tournament/double-round-robin.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
33
src/lib/server/tournament/index.ts
Normal file
33
src/lib/server/tournament/index.ts
Normal 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';
|
222
src/lib/server/tournament/round-robin.ts
Normal file
222
src/lib/server/tournament/round-robin.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
295
src/lib/server/tournament/single-elimination.ts
Normal file
295
src/lib/server/tournament/single-elimination.ts
Normal 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');
|
||||
}
|
||||
}
|
472
src/lib/server/tournament/swiss.ts
Normal file
472
src/lib/server/tournament/swiss.ts
Normal 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[];
|
||||
}
|
97
src/lib/server/tournament/types.ts
Normal file
97
src/lib/server/tournament/types.ts
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue