starting to have a bracket system that is previewable

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

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

68
.idea/codeStyles/Project.xml generated Normal file
View file

@ -0,0 +1,68 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

7
.idea/discord.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

12
.idea/flbxcup.iml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

12
.idea/material_theme_project_new.xml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-daa4d1a:1934e52663e:-7ffa" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/flbxcup.iml" filepath="$PROJECT_DIR$/.idea/flbxcup.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View file

@ -11,7 +11,7 @@
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"drizzle-zod": "^0.8.2", "drizzle-zod": "^0.8.2",
"lucide-svelte": "^0.525.0", "lucide-svelte": "^0.525.0",
"postgres": "^3.4.5", "pg": "^8.16.3",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelvet": "^11.0.5", "svelvet": "^11.0.5",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@ -27,6 +27,7 @@
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22", "@types/node": "^22",
"@types/pg": "^8.15.4",
"bits-ui": "^2.8.6", "bits-ui": "^2.8.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-kit": "^0.30.2", "drizzle-kit": "^0.30.2",
@ -59,6 +60,8 @@
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.6.0", "", {}, "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], "@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
@ -363,6 +366,8 @@
"@types/node": ["@types/node@22.16.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ=="], "@types/node": ["@types/node@22.16.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ=="],
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@ -391,6 +396,8 @@
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="], "bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
"buffer-builder": ["buffer-builder@0.2.0", "", {}, "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
@ -407,6 +414,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
@ -489,8 +498,12 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"immutable": ["immutable@5.1.3", "", {}, "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg=="],
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
"intl-messageformat": ["intl-messageformat@10.7.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug=="], "intl-messageformat": ["intl-messageformat@10.7.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug=="],
@ -581,6 +594,22 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
"pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
"pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
"pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
@ -591,6 +620,14 @@
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="], "prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="],
@ -613,8 +650,44 @@
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="], "runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"sass-embedded": ["sass-embedded@1.89.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-android-arm": "1.89.2", "sass-embedded-android-arm64": "1.89.2", "sass-embedded-android-riscv64": "1.89.2", "sass-embedded-android-x64": "1.89.2", "sass-embedded-darwin-arm64": "1.89.2", "sass-embedded-darwin-x64": "1.89.2", "sass-embedded-linux-arm": "1.89.2", "sass-embedded-linux-arm64": "1.89.2", "sass-embedded-linux-musl-arm": "1.89.2", "sass-embedded-linux-musl-arm64": "1.89.2", "sass-embedded-linux-musl-riscv64": "1.89.2", "sass-embedded-linux-musl-x64": "1.89.2", "sass-embedded-linux-riscv64": "1.89.2", "sass-embedded-linux-x64": "1.89.2", "sass-embedded-win32-arm64": "1.89.2", "sass-embedded-win32-x64": "1.89.2" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-Ack2K8rc57kCFcYlf3HXpZEJFNUX8xd8DILldksREmYXQkRHI879yy8q4mRDJgrojkySMZqmmmW1NxrFxMsYaA=="],
"sass-embedded-android-arm": ["sass-embedded-android-arm@1.89.2", "", { "os": "android", "cpu": "arm" }, "sha512-oHAPTboBHRZlDBhyRB6dvDKh4KvFs+DZibDHXbkSI6dBZxMTT+Yb2ivocHnctVGucKTLQeT7+OM5DjWHyynL/A=="],
"sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.89.2", "", { "os": "android", "cpu": "arm64" }, "sha512-+pq7a7AUpItNyPu61sRlP6G2A8pSPpyazASb+8AK2pVlFayCSPAEgpwpCE9A2/Xj86xJZeMizzKUHxM2CBCUxA=="],
"sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.89.2", "", { "os": "android", "cpu": "none" }, "sha512-HfJJWp/S6XSYvlGAqNdakeEMPOdhBkj2s2lN6SHnON54rahKem+z9pUbCriUJfM65Z90lakdGuOfidY61R9TYg=="],
"sass-embedded-android-x64": ["sass-embedded-android-x64@1.89.2", "", { "os": "android", "cpu": "x64" }, "sha512-BGPzq53VH5z5HN8de6jfMqJjnRe1E6sfnCWFd4pK+CAiuM7iw5Fx6BQZu3ikfI1l2GY0y6pRXzsVLdp/j4EKEA=="],
"sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.89.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UCm3RL/tzMpG7DsubARsvGUNXC5pgfQvP+RRFJo9XPIi6elopY5B6H4m9dRYDpHA+scjVthdiDwkPYr9+S/KGw=="],
"sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.89.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-D9WxtDY5VYtMApXRuhQK9VkPHB8R79NIIR6xxVlN2MIdEid/TZWi1MHNweieETXhWGrKhRKglwnHxxyKdJYMnA=="],
"sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.89.2", "", { "os": "linux", "cpu": "arm" }, "sha512-leP0t5U4r95dc90o8TCWfxNXwMAsQhpWxTkdtySDpngoqtTy3miMd7EYNYd1znI0FN1CBaUvbdCMbnbPwygDlA=="],
"sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.89.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-2N4WW5LLsbtrWUJ7iTpjvhajGIbmDR18ZzYRywHdMLpfdPApuHPMDF5CYzHbS+LLx2UAx7CFKBnj5LLjY6eFgQ=="],
"sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.89.2", "", { "os": "linux", "cpu": "arm" }, "sha512-Z6gG2FiVEEdxYHRi2sS5VIYBmp17351bWtOCUZ/thBM66+e70yiN6Eyqjz80DjL8haRUegNQgy9ZJqsLAAmr9g=="],
"sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.89.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-nTyuaBX6U1A/cG7WJh0pKD1gY8hbg1m2SnzsyoFG+exQ0lBX/lwTLHq3nyhF+0atv7YYhYKbmfz+sjPP8CZ9lw=="],
"sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.89.2", "", { "os": "linux", "cpu": "none" }, "sha512-N6oul+qALO0SwGY8JW7H/Vs0oZIMrRMBM4GqX3AjM/6y8JsJRxkAwnfd0fDyK+aICMFarDqQonQNIx99gdTZqw=="],
"sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.89.2", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FmWcdj/uyP8GiG9foxOCPfb5OAZG0uSVq80DKgVSC0U44AdGjvAvVZkrgFEcZ6cCqlNC2JfYmslB5iqdL7tg=="],
"sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.89.2", "", { "os": "linux", "cpu": "none" }, "sha512-g9nTbnD/3yhOaskeqeBQETbtfDQWRgsjHok6bn7DdAuwBsyrR3JlSFyqKc46pn9Xxd9SQQZU8AzM4IR+sY0A0w=="],
"sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.89.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ax7dKvzncyQzIl4r7012KCMBvJzOz4uwSNoyoM5IV6y5I1f5hEwI25+U4WfuTqdkv42taCMgpjZbh9ERr6JVMQ=="],
"sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.89.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-j96iJni50ZUsfD6tRxDQE2QSYQ2WrfHxeiyAXf41Kw0V4w5KYR/Sf6rCZQLMTUOHnD16qTMVpQi20LQSqf4WGg=="],
"sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.89.2", "", { "os": "win32", "cpu": "x64" }, "sha512-cS2j5ljdkQsb4PaORiClaVYynE9OAPZG/XjbOMxpQmjRIf7UroY4PEIH+Waf+y47PfXFX9SyxhYuw2NIKGbEng=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
@ -629,10 +702,14 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="],
"superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="], "superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.35.2", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-uW/rRXYrhZ7Dh4UQNZ0t+oVGL1dEM+95GavCO8afAk1IY2cPq9BcZv9C3um5aLIya2y8lIeLPxLII9ASGg9Dzw=="], "svelte": ["svelte@5.35.2", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-uW/rRXYrhZ7Dh4UQNZ0t+oVGL1dEM+95GavCO8afAk1IY2cPq9BcZv9C3um5aLIya2y8lIeLPxLII9ASGg9Dzw=="],
@ -649,6 +726,10 @@
"svelvet": ["svelvet@11.0.5", "", { "dependencies": { "svelvet": "^10.0.2", "uuid": "^11.0.5" }, "peerDependencies": { "svelte": ">=3.59.2 || ^4.0.0" } }, "sha512-wGDGh3bRKK06stu613DC+r4ujE7sWkFTAp4bfW6Impc2A89Ix0M/PzZAyeW229FMS5TF2GRDugHgpk0q0D8k2g=="], "svelvet": ["svelvet@11.0.5", "", { "dependencies": { "svelvet": "^10.0.2", "uuid": "^11.0.5" }, "peerDependencies": { "svelte": ">=3.59.2 || ^4.0.0" } }, "sha512-wGDGh3bRKK06stu613DC+r4ujE7sWkFTAp4bfW6Impc2A89Ix0M/PzZAyeW229FMS5TF2GRDugHgpk0q0D8k2g=="],
"sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
"sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
@ -697,6 +778,8 @@
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="], "validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
"vite": ["rolldown-vite@7.0.4", "", { "dependencies": { "@oxc-project/runtime": "0.75.0", "fdir": "^6.4.6", "lightningcss": "^1.30.1", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.23", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AcFt2mBWuwH3svDHcz8V5+K8Es1TuZOBDdJh6+ySkGSuNS5sEpRJqnopupeMfB8SHCAXVA6Wp75OQmTBZc+TgQ=="], "vite": ["rolldown-vite@7.0.4", "", { "dependencies": { "@oxc-project/runtime": "0.75.0", "fdir": "^6.4.6", "lightningcss": "^1.30.1", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.23", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AcFt2mBWuwH3svDHcz8V5+K8Es1TuZOBDdJh6+ySkGSuNS5sEpRJqnopupeMfB8SHCAXVA6Wp75OQmTBZc+TgQ=="],
"vite-plugin-devtools-json": ["vite-plugin-devtools-json@0.2.1", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-5aiNvf/iLTuLR1dUqoI5CLLGgeK2hd6u+tA+RIp7GUZDyAcM6ECaUEWOOtGpidbcxbkKq++KtmSqA3jhMbPwMA=="], "vite-plugin-devtools-json": ["vite-plugin-devtools-json@0.2.1", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-5aiNvf/iLTuLR1dUqoI5CLLGgeK2hd6u+tA+RIp7GUZDyAcM6ECaUEWOOtGpidbcxbkKq++KtmSqA3jhMbPwMA=="],
@ -705,6 +788,8 @@
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="], "yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],

View file

@ -27,6 +27,7 @@
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22", "@types/node": "^22",
"@types/pg": "^8.15.4",
"bits-ui": "^2.8.6", "bits-ui": "^2.8.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-kit": "^0.30.2", "drizzle-kit": "^0.30.2",
@ -56,7 +57,7 @@
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"drizzle-zod": "^0.8.2", "drizzle-zod": "^0.8.2",
"lucide-svelte": "^0.525.0", "lucide-svelte": "^0.525.0",
"postgres": "^3.4.5", "pg": "^8.16.3",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelvet": "^11.0.5", "svelvet": "^11.0.5",
"uuid": "^11.1.0", "uuid": "^11.1.0",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,36 +1,36 @@
<script lang="ts" module> <script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from 'tailwind-variants'; import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({ 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", 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: { variants: {
variant: { 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: 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: 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', "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', 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', ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: 'text-primary underline-offset-4 hover:underline' link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', 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', 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', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: 'size-9' icon: "size-9",
} },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default' size: "default",
} },
}); });
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant']; export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>['size']; export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> & export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & { WithElementRef<HTMLAnchorAttributes> & {
@ -42,11 +42,11 @@
<script lang="ts"> <script lang="ts">
let { let {
class: className, class: className,
variant = 'default', variant = "default",
size = 'default', size = "default",
ref = $bindable(null), ref = $bindable(null),
href = undefined, href = undefined,
type = 'button', type = "button",
disabled, disabled,
children, children,
...restProps ...restProps
@ -60,7 +60,7 @@
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? 'link' : undefined} role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}
{...restProps} {...restProps}
> >

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -12,7 +12,7 @@
<CalendarPrimitive.Cell <CalendarPrimitive.Cell
bind:ref bind:ref
class={cn( 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 className
)} )}
{...restProps} {...restProps}

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/index.js'; import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -13,22 +13,22 @@
<CalendarPrimitive.Day <CalendarPrimitive.Day
bind:ref bind:ref
class={cn( class={cn(
buttonVariants({ variant: 'ghost' }), 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', "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-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', "data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months // 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 // 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 // Unavailable
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through', "data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover // hover
'dark:hover:text-accent-foreground', "dark:hover:text-accent-foreground",
// focus // focus
'focus:border-ring focus:ring-ring/50 focus:relative', "focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans // inner spans
'[&>span]:text-xs [&>span]:opacity-70', "[&>span]:text-xs [&>span]:opacity-70",
className className
)} )}
{...restProps} {...restProps}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -11,6 +11,6 @@
<CalendarPrimitive.Grid <CalendarPrimitive.Grid
bind:ref 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} {...restProps}
/> />

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -12,7 +12,7 @@
<CalendarPrimitive.HeadCell <CalendarPrimitive.HeadCell
bind:ref bind:ref
class={cn( 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 className
)} )}
{...restProps} {...restProps}

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -12,7 +12,7 @@
<CalendarPrimitive.Header <CalendarPrimitive.Header
bind:ref bind:ref
class={cn( 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 className
)} )}
{...restProps} {...restProps}

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js'; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -14,7 +14,7 @@
<span <span
class={cn( 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 className
)} )}
> >
@ -33,7 +33,7 @@
{/each} {/each}
</select> </select>
<span <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" aria-hidden="true"
> >
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label} {monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { type WithElementRef, cn } from '$lib/utils.js'; import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script> </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?.()} {@render children?.()}
</div> </div>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -12,7 +12,7 @@
<div <div
bind:this={ref} 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} {...restProps}
> >
{@render children?.()} {@render children?.()}

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -13,7 +13,7 @@
<nav <nav
{...restProps} {...restProps}
bind:this={ref} 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?.()} {@render children?.()}
</nav> </nav>

View file

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js'; import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
variant = 'ghost', variant = "ghost",
...restProps ...restProps
}: CalendarPrimitive.NextButtonProps & { }: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant; variant?: ButtonVariant;
@ -23,7 +23,7 @@
bind:ref bind:ref
class={cn( class={cn(
buttonVariants({ variant }), 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 className
)} )}
children={children || Fallback} children={children || Fallback}

View file

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js'; import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from '$lib/utils.js'; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
variant = 'ghost', variant = "ghost",
...restProps ...restProps
}: CalendarPrimitive.PrevButtonProps & { }: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant; variant?: ButtonVariant;
@ -23,7 +23,7 @@
bind:ref bind:ref
class={cn( class={cn(
buttonVariants({ variant }), 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 className
)} )}
children={children || Fallback} children={children || Fallback}

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui'; import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js'; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let { let {
ref = $bindable(null), ref = $bindable(null),
@ -13,7 +13,7 @@
<span <span
class={cn( 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 className
)} )}
> >
@ -32,7 +32,7 @@
{/each} {/each}
</select> </select>
<span <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" aria-hidden="true"
> >
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label} {yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts"> <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 { let {
ref = $bindable(null), ref = $bindable(null),
@ -20,7 +20,7 @@
role="navigation" role="navigation"
aria-label="pagination" aria-label="pagination"
data-slot="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} {count}
{perPage} {perPage}
{siblingCount} {siblingCount}

View file

@ -7,6 +7,7 @@ import { env } from '$env/dynamic/private';
import { env as envPublic } from '$env/dynamic/public'; import { env as envPublic } from '$env/dynamic/public';
import { sessions, type Session } from './db/schema/sessions'; import { sessions, type Session } from './db/schema/sessions';
import { users } from './db/schema/users'; import { users } from './db/schema/users';
import { v7 } from 'uuid';
const DAY_IN_MS = 1000 * 60 * 60 * 24; const DAY_IN_MS = 1000 * 60 * 60 * 24;
@ -20,20 +21,7 @@ export const keycloak = new KeyCloak(
); );
function generateSecureRandomString(): string { function generateSecureRandomString(): string {
// Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion) return v7();
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;
} }
async function hashSecret(secret: string): Promise<Uint8Array> { async function hashSecret(secret: string): Promise<Uint8Array> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,13 @@
import * as t from 'drizzle-orm/pg-core'; import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../util'; import { timestamps, type TModelWithRelations } from '../util';
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
import { fields } from './fields'; import { fields } from './fields';
import { rounds } from './rounds'; import { rounds } from './rounds';
import { teams } from './teams'; import { teams } from './teams';
import { brackets } from './brackets'; 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', { export const matches = t.pgTable('matches', {
id: t.uuid('id').primaryKey().defaultRandom(), id: t.uuid('id').primaryKey().defaultRandom(),
@ -23,32 +24,46 @@ export const matches = t.pgTable('matches', {
position: t.integer('position').notNull(), position: t.integer('position').notNull(),
table: t.integer('table').notNull(), // swis/round robin table: t.integer('table').notNull(), // swis/round robin
round_id: t.uuid('round_id').notNull(), 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 ...timestamps
}); });
export const matchRelations = relations(matches, ({ one }) => ({ export const matchRelations = relations(matches, ({ one }) => ({
field: one(fields, { field: one(fields, {
fields: [matches.field_id], fields: [matches.field_id],
references: [fields.id] references: [fields.id],
relationName: 'matchField'
}), }),
round: one(rounds, { round: one(rounds, {
fields: [matches.round_id], fields: [matches.round_id],
references: [rounds.id] references: [rounds.id],
relationName: 'round'
}), }),
team1: one(teams, { team1: one(teams, {
fields: [matches.team1_id], fields: [matches.team1_id],
references: [teams.id] references: [teams.id],
relationName: 'matchTeam1'
}), }),
team2: one(teams, { team2: one(teams, {
fields: [matches.team2_id], fields: [matches.team2_id],
references: [teams.id] references: [teams.id],
relationName: 'matchTeam2'
}), }),
winner: one(teams, { winner: one(teams, {
fields: [matches.winner_id], 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 Match = typeof matches.$inferSelect;
export type MatchWithRelations = TModelWithRelations<'matches'>;

View file

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

View file

@ -25,11 +25,13 @@ export const permissionsToRoles = t.pgTable(
export const permissionsToRolesRelations = relations(permissionsToRoles, ({ one }) => ({ export const permissionsToRolesRelations = relations(permissionsToRoles, ({ one }) => ({
permission: one(permissions, { permission: one(permissions, {
fields: [permissionsToRoles.permission_id], fields: [permissionsToRoles.permission_id],
references: [permissions.id] references: [permissions.id],
relationName: 'permissionRole'
}), }),
role: one(roles, { role: one(roles, {
fields: [permissionsToRoles.role_id], 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 }) => ({ export const usersToRolesRelations = relations(usersToRoles, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [usersToRoles.user_id], fields: [usersToRoles.user_id],
references: [users.id] references: [users.id],
relationName: 'userRole'
}), }),
role: one(roles, { role: one(roles, {
fields: [usersToRoles.role_id], 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 }) => ({ export const usersToPermissionsRelations = relations(usersToPermissions, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [usersToPermissions.user_id], fields: [usersToPermissions.user_id],
references: [users.id] references: [users.id],
relationName: 'userPermission'
}), }),
permission: one(permissions, { permission: one(permissions, {
fields: [usersToPermissions.permission_id], fields: [usersToPermissions.permission_id],
references: [permissions.id] references: [permissions.id],
relationName: 'permissionUser'
}) })
})); }));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
import { insertRound } from '@/lib/server/db/queries/rounds';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import type { RoundInsert } from '@/lib/server/db/schema/rounds'; import type { RoundInsert } from '@/lib/server/db/schema/rounds';
import { SchedulingMode } from '@/types'; import { SchedulingMode } from '@/types';
import { createTournamentGenerator } from '@/lib/server/tournament';
import { getBracketsByListIds } from '@/lib/server/db/queries/brackets';
import { getCompetitionWithAll } from '@/lib/server/db/queries/competitions';
export const POST: RequestHandler = async ({ request, params }) => { export const POST: RequestHandler = async ({ request, params, locals }) => {
// get body from request // get body from request
// get competition id from url // get competition id from url
const body = await request.json(); const body = await request.json();
@ -17,30 +19,60 @@ export const POST: RequestHandler = async ({ request, params }) => {
throw new Error('Invalid competition id'); throw new Error('Invalid competition id');
} }
// get competition
const competition = await getCompetitionWithAll(competitionId);
if (!competition) {
throw new Error('Competition not found');
}
if (competition.owner != locals.user.id) {
throw new Error('YYou are not the owner of this competition');
}
let schedulingMode: SchedulingMode; let schedulingMode: SchedulingMode;
let name: string; let name: string;
let nbMmatches: number; let size: number;
try { try {
schedulingMode = SchedulingMode[body.scheduling_mode as keyof typeof SchedulingMode]; schedulingMode = SchedulingMode[body.scheduling_mode as keyof typeof SchedulingMode];
name = body.name; name = body.name;
nbMmatches = body.nb_matches; size = body.size;
} catch { } catch {
throw new Error('Invalid scheduling mode'); throw new Error('Invalid scheduling mode');
} }
// create round // generate bracket
const roundInsert: RoundInsert = { const tournamentGenerator = createTournamentGenerator(schedulingMode, competitionId, size);
nb_matches: nbMmatches, const bracketsIds = await tournamentGenerator.generateBrackets();
name: name, if (bracketsIds.length === 0) {
competition_id: competitionId, throw new Error('No brackets created');
scheduling_mode: schedulingMode, }
round_number: -1 const brackets = await getBracketsByListIds(bracketsIds, {});
};
const [round] = await insertRound(competitionId, roundInsert); if (brackets.length === 0) {
throw new Error('No brackets created');
}
return new Response(JSON.stringify(round), { let rounds: RoundInsert[] = [];
const firstRoundOfBracket: RoundInsert[] = [];
for (const bracketId of bracketsIds) {
const roundInserts = await tournamentGenerator.generateRounds(bracketId);
rounds = [...rounds, ...roundInserts];
firstRoundOfBracket.push(roundInserts[0]);
}
// only generate first round matches of each bracket
brackets.forEach(async (bracket, index) => {
if (!firstRoundOfBracket[index].id) {
throw new Error('First round of bracket not found');
}
await tournamentGenerator.generateMatches(firstRoundOfBracket[index].id, competition.teams);
});
return new Response(JSON.stringify(brackets), {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }

View file

@ -103,6 +103,5 @@ async function createUser(keycloakUserId: string, username: string, email: strin
// add user to default role // add user to default role
console.log(JSON.stringify(result));
return result; return result;
} }

View file

@ -7,8 +7,8 @@ import { and } from 'drizzle-orm';
import { teams } from '$lib/server/db/schema/teams'; import { teams } from '$lib/server/db/schema/teams';
import { superValidate } from 'sveltekit-superforms'; import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters'; import { zod } from 'sveltekit-superforms/adapters';
import { formSchema } from '$lib/components/rounds/create-schema';
import { getCompetitionWithAll } from '@/lib/server/db/queries/competitions'; import { getCompetitionWithAll } from '@/lib/server/db/queries/competitions';
import { fields } from '@/lib/server/db/schema/fields';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
try { try {
@ -23,8 +23,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
} }
return { return {
competition: competition, competition: competition
createForm: await superValidate(zod(formSchema))
}; };
} catch { } catch {
return redirect(302, '/'); return redirect(302, '/');
@ -145,6 +144,85 @@ export const actions = {
throw error(403, 'You are not authorized to update this competition'); throw error(403, 'You are not authorized to update this competition');
} }
return {
comp
};
},
addField: async (event) => {
const data = await event.request.formData();
const fieldName = data.get('field_name')?.toString();
if (!fieldName) {
throw error(400, 'Field name is required');
}
// get competition id from url
const id = event.params.id;
if (id === '') {
throw error(400, 'Invalid competition id');
}
let [competition] = await db
.select()
.from(competitions)
.leftJoin(fields, eq(competitions.id, fields.competition_id))
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
if (!competition) {
throw error(404, 'Competition not found');
}
// add field to competition's fields array
await db.insert(fields).values({
competition_id: id,
name: fieldName
});
const comp = await getCompetitionWithAll(id);
if (!comp || comp.owner !== event.locals.user.id) {
throw error(403, 'You are not authorized to update this competition');
}
return {
comp
};
},
deleteField: async (event) => {
const data = await event.request.formData();
const fieldId = data.get('id')?.toString();
if (!fieldId) {
throw error(400, 'Team name is required');
}
// get competition id from url
const id = event.params.id;
if (id === '') {
throw error(400, 'Invalid competition id');
}
let [competition] = await db
.select()
.from(competitions)
.leftJoin(fields, eq(competitions.id, fields.competition_id))
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
if (!competition) {
throw error(404, 'Competition not found');
}
// remove team to competition's teams array
await db.delete(fields).where(and(eq(fields.id, fieldId), eq(fields.competition_id, id)));
const comp = await getCompetitionWithAll(id);
if (!comp || comp.owner !== event.locals.user.id) {
throw error(403, 'You are not authorized to update this competition');
}
return { return {
comp comp
}; };

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import Round from '$lib/components/rounds/round.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import CardContent from '$lib/components/ui/card/card-content.svelte'; import CardContent from '$lib/components/ui/card/card-content.svelte';
@ -14,30 +13,46 @@
import Tabs from '$lib/components/ui/tabs/tabs.svelte'; import Tabs from '$lib/components/ui/tabs/tabs.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte'; import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { formatDate } from '$lib/utils'; import { formatDate } from '$lib/utils';
import CreateRound from '@/lib/components/rounds/create-round.svelte'; import Bracket from '@/lib/components/brackets/bracket.svelte';
import CreateBracket from '@/lib/components/brackets/create-bracket.svelte';
import { CalendarDays, Clock, MapPin, PlusIcon, Trash2Icon, Users } from 'lucide-svelte'; import { CalendarDays, Clock, MapPin, PlusIcon, Trash2Icon, Users } from 'lucide-svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import type { BreadcrumbItem } from '@/types';
import AppLayout from '@/lib/components/app-layout.svelte';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Home',
href: '/'
},
{
title: data.competition.name,
href: `/competitions/${data.competition.id}`
}
];
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let competition = $state(data.competition); let competition = $state(data.competition);
let tabValue = $state('rounds'); let tabValue = $state('general');
let showInputTeam = $state(false); let showInputTeam = $state(false);
let showInputDescription = $state(false); let showInputDescription = $state(false);
let description = $state(data.competition.description); let description = $state(data.competition.description);
let showAddRound = $state(false); let showInputField = $state(false);
let showAddBracket = $state(false);
$effect(() => { $effect(() => {
competition = data.competition; competition = data.competition;
}); });
$effect(() => { $effect(() => {
if (competition.rounds) { if (competition.brackets) {
// on new one added scroll to it // on new one added scroll to it
const round = competition.rounds[competition.rounds.length - 1]; const round = competition.brackets[competition.brackets.length - 1];
if (round) { if (round) {
console.log(round.id); console.log(round.id);
const roundElement = document.getElementById(`round-${round.id}`); const roundElement = document.getElementById(`round-${round.id}`);
@ -60,10 +75,24 @@
<title>{competition.name} | Competition Details</title> <title>{competition.name} | Competition Details</title>
</svelte:head> </svelte:head>
<div class="container mx-auto max-w-4xl px-4 py-8"> <AppLayout {breadcrumbs}>
<div class="mx-auto max-w-4xl px-4 py-8">
<div class="mb-8 flex items-start justify-between"> <div class="mb-8 flex items-start justify-between">
<div> <div class="grid grid-cols-2 grid-rows-2 gap-x-10">
<div class="flex items-center gap-10">
<h1 class="text-3xl font-bold">{competition.name}</h1> <h1 class="text-3xl font-bold">{competition.name}</h1>
<p>
{#if competition.description}
{competition.description}
{:else}
<span class="text-gray-500 italic">No description available</span>
{/if}
</p>
</div>
<div class="flex items-center gap-2">
<Clock class="h-5 w-5 text-blue-500" />
<span>Created: {formatDate(new Date(competition.created_at))}</span>
</div>
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">
<Badge variant="outline" class="flex items-center gap-1"> <Badge variant="outline" class="flex items-center gap-1">
<CalendarDays class="h-4 w-4" /> <CalendarDays class="h-4 w-4" />
@ -79,6 +108,18 @@
<Users class="h-4 w-4" /> <Users class="h-4 w-4" />
Teams: {competition.teams?.length || 0} Teams: {competition.teams?.length || 0}
</Badge> </Badge>
<Badge variant="outline" class="flex items-center gap-1">
<Clock class="h-4 w-4" />
{formatDate(new Date(competition.start_date))}
</Badge>
</div>
<div>
{#if competition.updated_at}
<div class="flex items-center gap-2">
<Clock class="h-5 w-5 text-green-500" />
<span>Updated: {formatDate(new Date(competition.updated_at))}</span>
</div>
{/if}
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@ -90,15 +131,17 @@
<div class="flex justify-between"> <div class="flex justify-between">
<TabsList> <TabsList>
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="rounds">Rounds</TabsTrigger> <TabsTrigger value="brackets">Brackets</TabsTrigger>
</TabsList> </TabsList>
<Button <Button
hidden={tabValue !== 'rounds' || !competition.rounds || competition.rounds.length === 0} hidden={tabValue !== 'brackets' ||
onclick={() => (showAddRound = !showAddRound)}>Add Round<PlusIcon /></Button !competition.brackets ||
competition.brackets.length === 0}
onclick={() => (showAddBracket = !showAddBracket)}>Add Round<PlusIcon /></Button
> >
</div> </div>
<TabsContent value="general"> <TabsContent value="general" class="flex w-full gap-6">
<div class="grid gap-6 md:grid-cols-3"> <!--- <div class="grid gap-6 md:grid-cols-3">
<Card class=" md:col-span-2"> <Card class=" md:col-span-2">
<CardHeader> <CardHeader>
<h2 class=" text-xl font-semibold">Description</h2> <h2 class=" text-xl font-semibold">Description</h2>
@ -148,9 +191,9 @@
</CardContent> </CardContent>
<CardFooter></CardFooter> <CardFooter></CardFooter>
</Card> </Card>
</div> </div>-->
<Card class="mt-6"> <Card class="w-full">
<CardHeader> <CardHeader>
<h2 class="text-xl font-semibold">Teams</h2> <h2 class="text-xl font-semibold">Teams</h2>
</CardHeader> </CardHeader>
@ -196,20 +239,65 @@
> >
</CardFooter> </CardFooter>
</Card> </Card>
<Card class="w-full">
<CardHeader><h2 class="text-xl font-semibold">Fields</h2></CardHeader>
<CardContent
>{#if !competition.fields || competition.fields.length === 0}
<p class="text-gray-500 italic">No Fields have been added yet</p>
{:else}
<div class="flex flex-col gap-4">
{#each competition.fields as field (field.id)}
<div class="">
<form
method="post"
action="?/deleteField"
class="flex items-center justify-between gap-2"
use:enhance
>
<div class="flex items-center gap-4">
<Users class="h-5 w-5 text-indigo-500" />
<input type="hidden" name="id" value={field.id} />
<span>{field.name}</span>
</div>
<Button formaction="?/deleteField" variant="destructive" type="submit"
>Delete<Trash2Icon /></Button
>
</form>
</div>
{/each}
</div>
{/if}
{#if showInputField}
<form method="post" action="?/addField" class="mt-4 flex gap-4" use:enhance>
<Input name="field_name" placeholder="Field Name" />
<Button formaction="?/addField" type="submit">Save</Button>
</form>
{/if}</CardContent
>
<CardFooter
><Button
disabled={showInputField}
onclick={() => {
showInputField = true;
}}>Add</Button
></CardFooter
>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="rounds"> <TabsContent value="brackets">
{#if showAddRound || !competition.rounds || competition.rounds.length === 0} {#if showAddBracket || !competition.brackets || competition.brackets.length === 0}
<CreateRound <CreateBracket
bind:showAddRound bind:showAddBracket
bind:rounds={competition.rounds} bind:brackets={competition.brackets}
competitionId={competition.id} competitionId={competition.id}
/> />
{:else} {:else}
{#each competition.rounds as round, round_number (round.id)} {#each competition.brackets as bracket (bracket.id)}
<Round id={round.id} {round} {round_number} /> <Bracket id={bracket.id} {bracket} />
{/each} {/each}
{/if} {/if}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
</AppLayout>

View file

@ -1,10 +1,12 @@
import { getCompetition } from '@/lib/server/db/queries/competitions'; import { getCompetition } from '@/lib/server/db/queries/competitions';
import { getRound } from '@/lib/server/db/queries/rounds'; import { getRound } from '@/lib/server/db/queries/rounds';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { getBracketWithRoundsAndMatches } from '@/lib/server/db/queries/brackets';
import { getTeamsByCompetition } from '@/lib/server/db/queries/teams';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const competitionId = params.id; const competitionId = params.id;
const roundId = params.round_id; const bracketId = params.bracket_id;
const { user } = locals; const { user } = locals;
if (!user) { if (!user) {
throw new Error('Unauthorized'); throw new Error('Unauthorized');
@ -12,7 +14,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (competitionId === '') { if (competitionId === '') {
throw new Error('Invalid competition id'); throw new Error('Invalid competition id');
} }
if (roundId === '') { if (bracketId === '') {
throw new Error('Invalid round id'); throw new Error('Invalid round id');
} }
@ -23,9 +25,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (competition.owner !== locals.user.id) { if (competition.owner !== locals.user.id) {
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
const round = await getRound(competitionId, roundId); const bracket = await getBracketWithRoundsAndMatches(bracketId);
if (!round) { if (!bracket) {
throw new Error('Invalid round'); throw new Error('Invalid round');
} }
return { competition: competition, round };
return { competition: competition, bracket };
}; };

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { SchedulingMode, type BreadcrumbItem } from '@/types';
import type { PageProps } from './$types';
import Single from '@/lib/components/brackets/types/single.svelte';
import AppLayout from '@/lib/components/app-layout.svelte';
let { data }: PageProps = $props();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Home',
href: '/'
},
{
title: data.competition.name,
href: `/competitions/${data.competition.id}`
},
{
title: data.bracket.name,
href: `/competitions/${data.competition.id}/bracket/${data.bracket.id}`
}
];
</script>
<AppLayout {breadcrumbs} fullWidth={true}>
{#if data.bracket.scheduling_mode === SchedulingMode.single}
<Single teams={data.teams} bracket={data.bracket} />
{:else if data.bracket.scheduling_mode === SchedulingMode.double}
double
{:else if data.bracket.scheduling_mode === SchedulingMode.round_robin}
round_robin
{:else if data.bracket.scheduling_mode === SchedulingMode.swiss}
swiss
{:else if data.bracket.scheduling_mode === SchedulingMode.double_round_robin}
double_round_robin
{/if}
</AppLayout>

View file

@ -1,7 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
{JSON.stringify(data)}

View file

@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { import {
Card, Card,
CardContent, CardContent,
@ -10,8 +7,23 @@
CardHeader, CardHeader,
CardTitle CardTitle
} from '$lib/components/ui/card'; } from '$lib/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Input } from '$lib/components/ui/input';
import { CalendarIcon, MapPin, Users, Trophy } from 'lucide-svelte'; import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import AppLayout from '@/lib/components/app-layout.svelte';
import type { BreadcrumbItem } from '@/types';
import { CalendarIcon, MapPin, Trophy, Users } from 'lucide-svelte';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Home',
href: '/'
},
{
title: 'New Competition',
href: '/competitions/new'
}
];
let formData = $state({ let formData = $state({
name: '', name: '',
@ -26,7 +38,12 @@
let isSubmitting = $state(false); let isSubmitting = $state(false);
</script> </script>
<div class="from-primary/5 to-primary/10 min-h-screen bg-gradient-to-br p-4"> <svelte:head>
<title>Create Competition | FlbxCup</title>
</svelte:head>
<AppLayout {breadcrumbs}>
<div class="from-primary/5 to-primary/10 min-h-screen bg-gradient-to-br p-4">
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<div <div
@ -44,7 +61,8 @@
<Trophy class="h-5 w-5" /> <Trophy class="h-5 w-5" />
Competition Details Competition Details
</CardTitle> </CardTitle>
<CardDescription>Fill in the information below to create your competition</CardDescription> <CardDescription>Fill in the information below to create your competition</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form method="POST" class="space-y-6"> <form method="POST" class="space-y-6">
@ -71,13 +89,10 @@
<Input <Input
name="start_date" name="start_date"
id="start_date" id="start_date"
type="datetime-local" type="date"
bind:value={formData.start_date} bind:value={formData.start_date}
class={errors.start_date ? 'border-red-500' : ''} class={errors.start_date ? 'border-red-500' : ''}
/> />
<CalendarIcon
class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400"
/>
</div> </div>
{#if errors.start_date} {#if errors.start_date}
<p class="text-sm text-red-500">{errors.start_date}</p> <p class="text-sm text-red-500">{errors.start_date}</p>
@ -164,4 +179,5 @@
</Card> </Card>
</div> </div>
</div> </div>
</div> </div>
</AppLayout>

View file

@ -21,9 +21,18 @@ export enum SchedulingMode {
double_round_robin = 'double_round_robin' double_round_robin = 'double_round_robin'
} }
export enum Bracket { export enum BracketType {
winners = 'winner', WINNER = 'WINNER',
losers = 'loser' LOSER = 'LOSER',
CONSOLATION = 'CONSOLATION',
MAIN = 'MAIN'
}
export enum MatchStatus {
PENDING = 'pending',
RUNNING = 'running',
FINISHED = 'finished',
CANCELLED = 'cancelled'
} }
export function enumToPgEnum<T extends Record<string, any>>( export function enumToPgEnum<T extends Record<string, any>>(