Merge pull request 'regionals' (#5) from development into main
Reviewed-on: #5
This commit is contained in:
@@ -96,6 +96,11 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: flxn-config
|
name: flxn-config
|
||||||
key: vite_spotify_redirect_uri
|
key: vite_spotify_redirect_uri
|
||||||
|
- name: COOKIE_DOMAIN
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: flxn-config
|
||||||
|
key: cookie_domain
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ data:
|
|||||||
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
|
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
|
||||||
s3_endpoint: "https://s3.yohler.net"
|
s3_endpoint: "https://s3.yohler.net"
|
||||||
s3_bucket: "flxn-dev"
|
s3_bucket: "flxn-dev"
|
||||||
|
cookie_domain: "dev.flexxon.app"
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ data:
|
|||||||
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
|
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
|
||||||
s3_endpoint: "https://s3.yohler.net"
|
s3_endpoint: "https://s3.yohler.net"
|
||||||
s3_bucket: "flxn-prod"
|
s3_bucket: "flxn-prod"
|
||||||
|
cookie_domain: "flexxon.app"
|
||||||
|
|||||||
109
pb_migrations/1771294794_created_groups.js
Normal file
109
pb_migrations/1771294794_created_groups.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_340646327",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3177167065",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "tournament",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1568971955",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2529305176",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "teams",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_3346940990",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "groups",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3346940990");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
52
pb_migrations/1771294861_updated_tournaments.js
Normal file
52
pb_migrations/1771294861_updated_tournaments.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("select3736761055")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(13, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3736761055",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "format",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(13, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select3736761055",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "format",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"single_elim",
|
||||||
|
"double_elim",
|
||||||
|
"groups",
|
||||||
|
"swiss",
|
||||||
|
"swiss_bracket",
|
||||||
|
"round_robin"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text3736761055")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
25
pb_migrations/1771294883_updated_tournaments.js
Normal file
25
pb_migrations/1771294883_updated_tournaments.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(14, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json118290348",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "group_config",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("json118290348")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
29
pb_migrations/1771294898_updated_tournaments.js
Normal file
29
pb_migrations/1771294898_updated_tournaments.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(15, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2982008523",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "phase",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text2982008523")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
47
pb_migrations/1771295070_updated_matches.js
Normal file
47
pb_migrations/1771295070_updated_matches.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(22, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3346940990",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation1841317061",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "group",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(23, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3987859035",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "match_type",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation1841317061")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text3987859035")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "FLXN IX",
|
|
||||||
"short_name": "FLXN",
|
"short_name": "FLXN",
|
||||||
|
"name": "FLXN",
|
||||||
|
"description": "Register for FLXN and view FLXN stats",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.png",
|
"src": "/favicon.png",
|
||||||
|
"type": "image/png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/favicon.png",
|
"src": "/favicon.png",
|
||||||
|
"type": "image/png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"display": "standalone"
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#1e293b",
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"scope": "/",
|
||||||
|
"categories": ["games", "social", "beer pong"],
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"shortcuts": []
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/static/img/duncer_cap_badge.png
Normal file
BIN
public/static/img/duncer_cap_badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
public/static/img/flip_cup_badge.png
Normal file
BIN
public/static/img/flip_cup_badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
public/static/img/gets_around_badge.png
Normal file
BIN
public/static/img/gets_around_badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
BIN
public/static/img/king_of_the_hill_badge.png
Normal file
BIN
public/static/img/king_of_the_hill_badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
BIN
public/static/img/one_up_badge.png
Normal file
BIN
public/static/img/one_up_badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -38,11 +38,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
|
|||||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
|
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||||
|
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||||
|
|
||||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||||
id: '/refresh-session',
|
id: '/refresh-session',
|
||||||
@@ -191,6 +193,12 @@ const AuthedAdminTournamentsIndexRoute =
|
|||||||
path: '/tournaments/',
|
path: '/tournaments/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedTournamentsIdGroupsRoute =
|
||||||
|
AuthedTournamentsIdGroupsRouteImport.update({
|
||||||
|
id: '/tournaments/$id/groups',
|
||||||
|
path: '/tournaments/$id/groups',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedTournamentsIdBracketRoute =
|
const AuthedTournamentsIdBracketRoute =
|
||||||
AuthedTournamentsIdBracketRouteImport.update({
|
AuthedTournamentsIdBracketRouteImport.update({
|
||||||
id: '/tournaments/$id/bracket',
|
id: '/tournaments/$id/bracket',
|
||||||
@@ -221,6 +229,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
|
|||||||
path: '/tournaments/$id/teams',
|
path: '/tournaments/$id/teams',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTournamentsIdAssignPartnersRoute =
|
||||||
|
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
|
||||||
|
id: '/tournaments/$id/assign-partners',
|
||||||
|
path: '/tournaments/$id/assign-partners',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
@@ -251,7 +265,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/': typeof AuthedAdminIndexRoute
|
'/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
@@ -285,7 +301,9 @@ export interface FileRoutesByTo {
|
|||||||
'/admin': typeof AuthedAdminIndexRoute
|
'/admin': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
@@ -322,7 +340,9 @@ export interface FileRoutesById {
|
|||||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
|
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
@@ -359,7 +379,9 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/tournaments/'
|
| '/tournaments/'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
|
| '/tournaments/$id/groups'
|
||||||
| '/admin/tournaments/'
|
| '/admin/tournaments/'
|
||||||
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
@@ -393,7 +415,9 @@ export interface FileRouteTypes {
|
|||||||
| '/admin'
|
| '/admin'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
|
| '/tournaments/$id/groups'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
@@ -429,7 +453,9 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/admin/'
|
| '/_authed/admin/'
|
||||||
| '/_authed/tournaments/'
|
| '/_authed/tournaments/'
|
||||||
| '/_authed/tournaments/$id/bracket'
|
| '/_authed/tournaments/$id/bracket'
|
||||||
|
| '/_authed/tournaments/$id/groups'
|
||||||
| '/_authed/admin/tournaments/'
|
| '/_authed/admin/tournaments/'
|
||||||
|
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||||
| '/_authed/admin/tournaments/$id/teams'
|
| '/_authed/admin/tournaments/$id/teams'
|
||||||
| '/_authed/admin/tournaments/run/$id'
|
| '/_authed/admin/tournaments/run/$id'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
@@ -660,6 +686,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/tournaments/$id/groups': {
|
||||||
|
id: '/_authed/tournaments/$id/groups'
|
||||||
|
path: '/tournaments/$id/groups'
|
||||||
|
fullPath: '/tournaments/$id/groups'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/tournaments/$id/bracket': {
|
'/_authed/tournaments/$id/bracket': {
|
||||||
id: '/_authed/tournaments/$id/bracket'
|
id: '/_authed/tournaments/$id/bracket'
|
||||||
path: '/tournaments/$id/bracket'
|
path: '/tournaments/$id/bracket'
|
||||||
@@ -695,6 +728,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/admin/tournaments/$id/assign-partners': {
|
||||||
|
id: '/_authed/admin/tournaments/$id/assign-partners'
|
||||||
|
path: '/tournaments/$id/assign-partners'
|
||||||
|
fullPath: '/admin/tournaments/$id/assign-partners'
|
||||||
|
preLoaderRoute: typeof AuthedAdminTournamentsIdAssignPartnersRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,6 +744,7 @@ interface AuthedAdminRouteChildren {
|
|||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||||
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
@@ -715,6 +756,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
|||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
|
AuthedAdminTournamentsIdAssignPartnersRoute:
|
||||||
|
AuthedAdminTournamentsIdAssignPartnersRoute,
|
||||||
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||||
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||||
@@ -735,6 +778,7 @@ interface AuthedRouteChildren {
|
|||||||
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
|
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
|
||||||
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
||||||
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
||||||
|
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||||
@@ -748,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
|||||||
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
|
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
|
||||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||||
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||||
|
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteWithChildren =
|
const AuthedRouteWithChildren =
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
|
|||||||
import { AuthContextType } from "@/contexts/auth-context";
|
import { AuthContextType } from "@/contexts/auth-context";
|
||||||
import Providers from "@/features/core/components/providers";
|
import Providers from "@/features/core/components/providers";
|
||||||
import { SessionMonitor } from "@/components/session-monitor";
|
import { SessionMonitor } from "@/components/session-monitor";
|
||||||
|
import { IOSInstallPrompt } from "@/components/ios-install-prompt";
|
||||||
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
||||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
@@ -47,6 +48,12 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{ property: 'og:type', content: 'website' },
|
{ property: 'og:type', content: 'website' },
|
||||||
{ property: 'og:site_name', content: 'FLXN IX' },
|
{ property: 'og:site_name', content: 'FLXN IX' },
|
||||||
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
|
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
|
||||||
|
{ property: 'og:image:width', content: '512' },
|
||||||
|
{ property: 'og:image:height', content: '512' },
|
||||||
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
||||||
|
{ name: 'apple-mobile-web-app-title', content: 'FLXN' },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
@@ -96,23 +103,35 @@ export const Route = createRootRouteWithContext<{
|
|||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
notFoundComponent: () => <Navigate to="/" />,
|
notFoundComponent: () => <Navigate to="/" />,
|
||||||
beforeLoad: async ({ context, location }) => {
|
beforeLoad: async ({ context, location }) => {
|
||||||
// Skip auth check for refresh-session route to avoid infinite loops
|
const publicRoutes = ['/login', '/logout', '/refresh-session'];
|
||||||
if (location.pathname === '/refresh-session') {
|
if (publicRoutes.some(route => location.pathname.startsWith(route))) {
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.pathname === '/login' || location.pathname === '/logout') {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// https://github.com/TanStack/router/discussions/3531
|
|
||||||
const auth = await ensureServerQueryData(
|
const auth = await ensureServerQueryData(
|
||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
||||||
|
|
||||||
|
const sessionExists = await doesSessionExist();
|
||||||
|
if (sessionExists) {
|
||||||
|
try {
|
||||||
|
await attemptRefreshingSession();
|
||||||
|
const auth = await ensureServerQueryData(
|
||||||
|
context.queryClient,
|
||||||
|
playerQueries.auth()
|
||||||
|
);
|
||||||
|
return { auth };
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -128,6 +147,7 @@ function RootComponent() {
|
|||||||
<RootDocument>
|
<RootDocument>
|
||||||
<Providers>
|
<Providers>
|
||||||
<SessionMonitor />
|
<SessionMonitor />
|
||||||
|
<IOSInstallPrompt />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Providers>
|
</Providers>
|
||||||
</RootDocument>
|
</RootDocument>
|
||||||
|
|||||||
167
src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
Normal file
167
src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
|
||||||
|
import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
|
||||||
|
import TeamAssignmentPreview from "@/features/tournaments/components/team-assignment-preview";
|
||||||
|
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||||
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||||
|
return { tournament };
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `Manage ${context.tournament.name}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TeamAssignment {
|
||||||
|
player1: PlayerInfo;
|
||||||
|
player2: PlayerInfo;
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: freeAgents } = useFreeAgents(id);
|
||||||
|
const [assignments, setAssignments] = useState<TeamAssignment[] | null>(null);
|
||||||
|
const [currentSeed, setCurrentSeed] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const generateMutation = useGenerateRandomTeams();
|
||||||
|
const confirmMutation = useConfirmTeamAssignments();
|
||||||
|
|
||||||
|
const hasOddPlayers = freeAgents.length % 2 !== 0;
|
||||||
|
const hasEnoughPlayers = freeAgents.length >= 2;
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
generateMutation.mutate(
|
||||||
|
{ data: { tournamentId: id, seed: currentSeed } },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setAssignments(result.assignments);
|
||||||
|
setCurrentSeed(result.seed);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReroll = () => {
|
||||||
|
if (currentSeed === undefined) return;
|
||||||
|
generateMutation.mutate(
|
||||||
|
{ data: { tournamentId: id, seed: currentSeed + 1 } },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setAssignments(result.assignments);
|
||||||
|
setCurrentSeed(result.seed);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!assignments) return;
|
||||||
|
|
||||||
|
const formattedAssignments = assignments.map((a) => ({
|
||||||
|
player1Id: a.player1.id,
|
||||||
|
player2Id: a.player2.id,
|
||||||
|
teamName: a.teamName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
confirmMutation.mutate(
|
||||||
|
{ data: { tournamentId: id, assignments: formattedAssignments } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(id).queryKey });
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentQueries.free_agents(id).queryKey });
|
||||||
|
navigate({ to: "/admin/tournaments/$id", params: { id } });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" pos="relative">
|
||||||
|
<LoadingOverlay visible={confirmMutation.isPending} />
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs" align="baseline">
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{freeAgents.length}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{freeAgents.length === 1 ? "player enrolled" : "players enrolled"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!hasEnoughPlayers && (
|
||||||
|
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
||||||
|
Need at least 2 players to create teams
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOddPlayers && (
|
||||||
|
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
||||||
|
Cannot create teams with an odd number of players. Please have one player unenroll.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!assignments && hasEnoughPlayers && !hasOddPlayers && (
|
||||||
|
<Button
|
||||||
|
leftSection={<ShuffleIcon size={18} />}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
loading={generateMutation.isPending}
|
||||||
|
>
|
||||||
|
Generate Random Pairings
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{assignments && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
Partner Assignments
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={<ShuffleIcon size={16} />}
|
||||||
|
onClick={handleReroll}
|
||||||
|
loading={generateMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Re-roll
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<CheckCircleIcon size={18} />}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
loading={confirmMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Confirm & Create Teams
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TeamAssignmentPreview assignments={assignments} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
} from "@/features/tournaments/queries";
|
} from "@/features/tournaments/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
||||||
import { Container } from "@mantine/core";
|
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
|
||||||
|
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
||||||
|
import { Container, Stack, Divider, Title } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
@@ -43,6 +45,20 @@ function RouteComponent() {
|
|||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
|
|
||||||
|
const hasGroupStage = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const hasKnockout = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) => match.round !== -1) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const knockoutBracketPopulated = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) =>
|
||||||
|
match.round === 0 && match.lid >= 0 && (match.home || match.away)
|
||||||
|
) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(() => {
|
const bracket: BracketData = useMemo(() => {
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
return { winners: [], losers: [] };
|
return { winners: [], losers: [] };
|
||||||
@@ -52,6 +68,7 @@ function RouteComponent() {
|
|||||||
const losersMap = new Map<number, Match[]>();
|
const losersMap = new Map<number, Match[]>();
|
||||||
|
|
||||||
tournament.matches
|
tournament.matches
|
||||||
|
.filter((match) => match.round !== -1)
|
||||||
.sort((a, b) => a.lid - b.lid)
|
.sort((a, b) => a.lid - b.lid)
|
||||||
.forEach((match) => {
|
.forEach((match) => {
|
||||||
if (!match.is_losers_bracket) {
|
if (!match.is_losers_bracket) {
|
||||||
@@ -79,15 +96,49 @@ function RouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
{ isAdmin && <SpotifyControlsBar />}
|
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
|
||||||
{tournament.matches?.length ? (
|
{tournament.matches?.length ? (
|
||||||
<BracketView bracket={bracket} showControls />
|
hasGroupStage && hasKnockout ? (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<GroupStageView
|
||||||
|
groups={tournament.groups || []}
|
||||||
|
matches={tournament.matches}
|
||||||
|
showControls
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
|
isRegional={tournament.regional}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<div>
|
||||||
|
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
||||||
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
) : hasGroupStage ? (
|
||||||
|
<GroupStageView
|
||||||
|
groups={tournament.groups || []}
|
||||||
|
matches={tournament.matches}
|
||||||
|
showControls
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
|
isRegional={tournament.regional}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<SeedTournament
|
tournament.regional === true ? (
|
||||||
tournamentId={tournament.id}
|
<SetupGroupStage
|
||||||
teams={tournament.teams || []}
|
tournamentId={tournament.id}
|
||||||
isRegional={tournament.regional}
|
teams={tournament.teams || []}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SeedTournament
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
teams={tournament.teams || []}
|
||||||
|
isRegional={tournament.regional}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
useTournament,
|
useTournament,
|
||||||
} from "@/features/tournaments/queries";
|
} from "@/features/tournaments/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
|
||||||
import { Container } from "@mantine/core";
|
import { Container } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
@@ -18,7 +17,7 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
|||||||
queryClient,
|
queryClient,
|
||||||
tournamentQueries.details(params.id)
|
tournamentQueries.details(params.id)
|
||||||
);
|
);
|
||||||
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
};
|
};
|
||||||
@@ -26,7 +25,6 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
|||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
showSpotifyPanel: true,
|
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `${context.tournament.name}`,
|
title: `${context.tournament.name}`,
|
||||||
@@ -48,6 +46,7 @@ function RouteComponent() {
|
|||||||
const losersMap = new Map<number, Match[]>();
|
const losersMap = new Map<number, Match[]>();
|
||||||
|
|
||||||
tournament.matches
|
tournament.matches
|
||||||
|
.filter((match) => match.round !== -1)
|
||||||
.sort((a, b) => a.lid - b.lid)
|
.sort((a, b) => a.lid - b.lid)
|
||||||
.forEach((match) => {
|
.forEach((match) => {
|
||||||
if (!match.is_losers_bracket) {
|
if (!match.is_losers_bracket) {
|
||||||
@@ -75,7 +74,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
<BracketView bracket={bracket} />
|
<BracketView bracket={bracket} groupConfig={tournament.group_config} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/app/routes/_authed/tournaments/$id.groups.tsx
Normal file
46
src/app/routes/_authed/tournaments/$id.groups.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
tournamentQueries,
|
||||||
|
useTournament,
|
||||||
|
} from "@/features/tournaments/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
||||||
|
import { Container } from "@mantine/core";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.tournament.name}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<GroupStageView
|
||||||
|
groups={tournament.groups || []}
|
||||||
|
matches={tournament.matches || []}
|
||||||
|
isRegional={tournament.regional}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/ios-install-prompt.tsx
Normal file
59
src/components/ios-install-prompt.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||||
|
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||||
|
|
||||||
|
export function IOSInstallPrompt() {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||||
|
const isAndroid = /Android/.test(navigator.userAgent)
|
||||||
|
|
||||||
|
const isInStandaloneMode =
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
('standalone' in window.navigator && (window.navigator as any).standalone)
|
||||||
|
|
||||||
|
const hasBeenDismissed = localStorage.getItem('pwa-install-prompt-dismissed') === 'true'
|
||||||
|
|
||||||
|
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
|
||||||
|
setPlatform(isIOS ? 'ios' : 'android')
|
||||||
|
const timer = setTimeout(() => setShow(true), 3000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
|
||||||
|
setShow(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!show || !platform) return null
|
||||||
|
|
||||||
|
const instructions = platform === 'ios'
|
||||||
|
? 'Tap Share → Add to Home Screen'
|
||||||
|
: 'Tap Menu (⋮) → Add to Home screen'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
||||||
|
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
|
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
||||||
|
Please install FLXN • This will save me Twilio credits as you won't be signed out!
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
||||||
|
{instructions}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}>
|
||||||
|
<XIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
|
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
|
||||||
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
|
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
|
||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
|
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
|
||||||
import { logger } from '@/lib/supertokens';
|
import { logger } from '@/lib/supertokens';
|
||||||
|
|
||||||
export function SessionMonitor() {
|
export function SessionMonitor() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const lastRefreshTimeRef = useRef<number>(0);
|
const lastRefreshTimeRef = useRef<number>(0);
|
||||||
const REFRESH_COOLDOWN = 30 * 1000;
|
const REFRESH_COOLDOWN = 30 * 1000;
|
||||||
|
|
||||||
@@ -49,12 +51,14 @@ export function SessionMonitor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleVisibilityChange();
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [navigate]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,17 +30,7 @@ const TeamAvatar = ({
|
|||||||
const hasNoLogo = !team.logo;
|
const hasNoLogo = !team.logo;
|
||||||
const hasTwoPlayers = team.players?.length === 2;
|
const hasTwoPlayers = team.players?.length === 2;
|
||||||
|
|
||||||
let shouldShowPlayerAvatars = false;
|
const shouldShowPlayerAvatars = isRegional === true && hasTwoPlayers && hasNoLogo;
|
||||||
|
|
||||||
if (isRegional !== undefined) {
|
|
||||||
shouldShowPlayerAvatars = isRegional && hasTwoPlayers && hasNoLogo;
|
|
||||||
} else {
|
|
||||||
const tournaments = (team as any).tournaments;
|
|
||||||
const hasTournaments = tournaments && tournaments.length > 0;
|
|
||||||
const allTournamentsAreRegional = hasTournaments && tournaments.every((t: any) => t.regional === true);
|
|
||||||
|
|
||||||
shouldShowPlayerAvatars = hasTwoPlayers && hasNoLogo && (allTournamentsAreRegional || !hasTournaments);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowPlayerAvatars && team.players?.length === 2) {
|
if (shouldShowPlayerAvatars && team.players?.length === 2) {
|
||||||
const playerSize = size * 0.6;
|
const playerSize = size * 0.6;
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { Match } from "@/features/matches/types";
|
|||||||
|
|
||||||
interface BracketViewProps {
|
interface BracketViewProps {
|
||||||
bracket: BracketData;
|
bracket: BracketData;
|
||||||
showControls?: boolean
|
showControls?: boolean;
|
||||||
|
groupConfig?: {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
|
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
const map: Record<number, number> = {};
|
const map: Record<number, number> = {};
|
||||||
@@ -32,14 +36,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
|
|||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Winners Bracket
|
Winners Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} />
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||||
</div>
|
</div>
|
||||||
{bracket.losers && (
|
{bracket.losers && (
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
Losers Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} />
|
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ interface BracketProps {
|
|||||||
rounds: Match[][];
|
rounds: Match[][];
|
||||||
orders: Record<number, number>;
|
orders: Record<number, number>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
|
groupConfig?: {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Bracket: React.FC<BracketProps> = ({
|
export const Bracket: React.FC<BracketProps> = ({
|
||||||
rounds,
|
rounds,
|
||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
|
groupConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
@@ -132,6 +137,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
match={match}
|
match={match}
|
||||||
orders={orders}
|
orders={orders}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
|
groupConfig={groupConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,16 +17,53 @@ interface MatchCardProps {
|
|||||||
match: Match;
|
match: Match;
|
||||||
orders: Record<number, number>;
|
orders: Record<number, number>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
|
groupConfig?: {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||||
match,
|
match,
|
||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
|
groupConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
const { playTrack, pause } = useSpotifyPlayback();
|
const { playTrack, pause } = useSpotifyPlayback();
|
||||||
|
|
||||||
|
const getGroupLabel = useCallback((seed: number | undefined) => {
|
||||||
|
if (!seed || !groupConfig) return undefined;
|
||||||
|
|
||||||
|
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
||||||
|
const numGroups = groupConfig.num_groups;
|
||||||
|
const advancePerGroup = groupConfig.advance_per_group;
|
||||||
|
|
||||||
|
const pairIndex = Math.floor((seed - 1) / 2);
|
||||||
|
const isFirstInPair = (seed - 1) % 2 === 0;
|
||||||
|
|
||||||
|
if (isFirstInPair) {
|
||||||
|
const groupIndex = pairIndex % numGroups;
|
||||||
|
const rankIndex = Math.floor(pairIndex / numGroups);
|
||||||
|
|
||||||
|
const rank = rankIndex + 1;
|
||||||
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
|
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
||||||
|
|
||||||
|
return `${groupName} ${rankSuffix}`;
|
||||||
|
} else {
|
||||||
|
const groupIndex = (pairIndex + 1) % numGroups;
|
||||||
|
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
|
||||||
|
|
||||||
|
const rank = rankIndex + 1;
|
||||||
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
|
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
||||||
|
|
||||||
|
return `${groupName} ${rankSuffix}`;
|
||||||
|
}
|
||||||
|
}, [groupConfig]);
|
||||||
|
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: orders[match.home_from_lid],
|
from: orders[match.home_from_lid],
|
||||||
@@ -39,8 +76,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups > match.away_cups,
|
match.home_cups > match.away_cups,
|
||||||
|
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined,
|
||||||
}),
|
}),
|
||||||
[match]
|
[match, getGroupLabel]
|
||||||
);
|
);
|
||||||
const awaySlot = useMemo(
|
const awaySlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -54,8 +92,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups > match.home_cups,
|
match.away_cups > match.home_cups,
|
||||||
|
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined,
|
||||||
}),
|
}),
|
||||||
[match]
|
[match, getGroupLabel]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showToolbar = useMemo(
|
const showToolbar = useMemo(
|
||||||
@@ -179,8 +218,11 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
data: match.id,
|
data: match.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play walkout sequence after starting the match
|
// Skip announcements for regional tournaments
|
||||||
if (hasWalkoutData && match.home?.name && match.away?.name) {
|
const isRegional = match.tournament?.regional === true;
|
||||||
|
|
||||||
|
// Play walkout sequence after starting the match (only for non-regional tournaments)
|
||||||
|
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
||||||
try {
|
try {
|
||||||
const homeTeam = match.home as Team;
|
const homeTeam = match.home as Team;
|
||||||
const awayTeam = match.away as Team;
|
const awayTeam = match.away as Team;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface MatchSlotProps {
|
|||||||
seed?: number;
|
seed?: number;
|
||||||
cups?: number;
|
cups?: number;
|
||||||
isWinner?: boolean;
|
isWinner?: boolean;
|
||||||
|
groupLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||||
@@ -19,7 +20,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
team,
|
team,
|
||||||
seed,
|
seed,
|
||||||
cups,
|
cups,
|
||||||
isWinner
|
isWinner,
|
||||||
|
groupLabel
|
||||||
}) => (
|
}) => (
|
||||||
<Flex
|
<Flex
|
||||||
align="stretch"
|
align="stretch"
|
||||||
@@ -34,7 +36,7 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
<Flex align="center" gap={4} flex={1}>
|
<Flex align="center" gap={4} flex={1}>
|
||||||
{team ? (
|
{team ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
truncate
|
truncate
|
||||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
@@ -43,18 +45,22 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{isWinner && (
|
{isWinner && (
|
||||||
<CrownIcon
|
<CrownIcon
|
||||||
size={14}
|
size={14}
|
||||||
weight="fill"
|
weight="fill"
|
||||||
style={{
|
style={{
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
marginLeft: '2px',
|
marginLeft: '2px',
|
||||||
marginTop: '-1px',
|
marginTop: '-1px',
|
||||||
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
||||||
flexShrink: 0
|
flexShrink: 0
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : groupLabel ? (
|
||||||
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
{groupLabel}
|
||||||
|
</Text>
|
||||||
) : from ? (
|
) : from ? (
|
||||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
{from_loser ? "Loser" : "Winner"} of Match {from}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { logger } from "@/lib/logger";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import brackets from "@/features/bracket/utils";
|
import brackets from "@/features/bracket/utils";
|
||||||
import { MatchInput } from "@/features/matches/types";
|
import { Match, MatchInput } from "@/features/matches/types";
|
||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { serverEvents } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
@@ -164,6 +164,189 @@ export const startMatch = createServerFn()
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const populateKnockoutBracket = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data: { tournamentId } }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tournament.group_config) {
|
||||||
|
throw new Error("Tournament must have group_config");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) {
|
||||||
|
logger.info('Populating knockout bracket', { tournamentId });
|
||||||
|
|
||||||
|
const groups = await pbAdmin.getGroupsByTournament(tournamentId);
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
throw new Error("No groups found for tournament");
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
logger.info('Processing group', {
|
||||||
|
groupId: group.id,
|
||||||
|
groupOrder: group.order,
|
||||||
|
teamsCount: group.teams?.length,
|
||||||
|
teams: group.teams
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
|
||||||
|
const completedMatches = groupMatches.filter(m => m.status === "ended");
|
||||||
|
|
||||||
|
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
||||||
|
|
||||||
|
for (const team of group.teams || []) {
|
||||||
|
// group.teams can be either team objects or just team ID strings
|
||||||
|
const teamId = typeof team === 'string' ? team : team.id;
|
||||||
|
standings.set(teamId, {
|
||||||
|
teamId,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
cups_for: 0,
|
||||||
|
cups_against: 0,
|
||||||
|
cup_differential: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of completedMatches) {
|
||||||
|
if (!match.home || !match.away) continue;
|
||||||
|
|
||||||
|
const homeStanding = standings.get(match.home.id);
|
||||||
|
const awayStanding = standings.get(match.away.id);
|
||||||
|
|
||||||
|
if (homeStanding && awayStanding) {
|
||||||
|
homeStanding.cups_for += match.home_cups;
|
||||||
|
homeStanding.cups_against += match.away_cups;
|
||||||
|
awayStanding.cups_for += match.away_cups;
|
||||||
|
awayStanding.cups_against += match.home_cups;
|
||||||
|
|
||||||
|
if (match.home_cups > match.away_cups) {
|
||||||
|
homeStanding.wins++;
|
||||||
|
awayStanding.losses++;
|
||||||
|
} else {
|
||||||
|
awayStanding.wins++;
|
||||||
|
homeStanding.losses++;
|
||||||
|
}
|
||||||
|
|
||||||
|
homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
|
||||||
|
awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedStandings = Array.from(standings.values()).sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
||||||
|
return b.cups_for - a.cups_for;
|
||||||
|
});
|
||||||
|
|
||||||
|
const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group);
|
||||||
|
logger.info('Top teams from group', {
|
||||||
|
groupId: group.id,
|
||||||
|
topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential }))
|
||||||
|
});
|
||||||
|
|
||||||
|
topTeams.forEach((standing, index) => {
|
||||||
|
qualifiedTeams.push({
|
||||||
|
teamId: standing.teamId,
|
||||||
|
groupOrder: group.order,
|
||||||
|
rank: index + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Qualified teams', { qualifiedTeams });
|
||||||
|
|
||||||
|
const orderedTeamIds: string[] = [];
|
||||||
|
const maxRank = groupConfig.advance_per_group;
|
||||||
|
|
||||||
|
for (let rank = 1; rank <= maxRank; rank++) {
|
||||||
|
const teamsAtRank = qualifiedTeams
|
||||||
|
.filter(t => t.rank === rank)
|
||||||
|
.sort((a, b) => a.groupOrder - b.groupOrder);
|
||||||
|
orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Ordered team IDs', { orderedTeamIds });
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
const knockoutMatches = (tournament.matches || [])
|
||||||
|
.filter((m: Match) => m.round >= 0 && m.lid >= 0)
|
||||||
|
.sort((a: Match, b: Match) => a.lid - b.lid);
|
||||||
|
|
||||||
|
const seedToTeamId = new Map<number, string>();
|
||||||
|
orderedTeamIds.forEach((teamId, index) => {
|
||||||
|
seedToTeamId.set(index + 1, teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Seed to team mapping', {
|
||||||
|
seedToTeamId: Array.from(seedToTeamId.entries()),
|
||||||
|
orderedTeamIds
|
||||||
|
});
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (const match of knockoutMatches) {
|
||||||
|
if (match.round === 0) {
|
||||||
|
const updates: any = {};
|
||||||
|
|
||||||
|
if (match.home_seed) {
|
||||||
|
const teamId = seedToTeamId.get(match.home_seed);
|
||||||
|
logger.info('Looking up home seed', {
|
||||||
|
matchId: match.id,
|
||||||
|
home_seed: match.home_seed,
|
||||||
|
teamId
|
||||||
|
});
|
||||||
|
if (teamId) {
|
||||||
|
updates.home = teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.away_seed) {
|
||||||
|
const teamId = seedToTeamId.get(match.away_seed);
|
||||||
|
logger.info('Looking up away seed', {
|
||||||
|
matchId: match.id,
|
||||||
|
away_seed: match.away_seed,
|
||||||
|
teamId
|
||||||
|
});
|
||||||
|
if (teamId) {
|
||||||
|
updates.away = teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.home && updates.away) {
|
||||||
|
updates.status = "ready";
|
||||||
|
} else if (updates.home || updates.away) {
|
||||||
|
updates.status = "tbd";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
logger.info('Updating match', { matchId: match.id, updates });
|
||||||
|
await pbAdmin.updateMatch(match.id, updates);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length });
|
||||||
|
|
||||||
|
await pbAdmin.updateTournament(tournamentId, {
|
||||||
|
phase: "knockout"
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Knockout bracket populated successfully', { tournamentId });
|
||||||
|
}
|
||||||
|
|
||||||
const endMatchSchema = z.object({
|
const endMatchSchema = z.object({
|
||||||
matchId: z.string(),
|
matchId: z.string(),
|
||||||
home_cups: z.number(),
|
home_cups: z.number(),
|
||||||
@@ -190,19 +373,25 @@ export const endMatch = createServerFn()
|
|||||||
ot_count,
|
ot_count,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (match.lid === -1) {
|
||||||
|
serverEvents.emit("match", {
|
||||||
|
type: "match",
|
||||||
|
matchId: match.id,
|
||||||
|
tournamentId: match.tournament.id
|
||||||
|
});
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
||||||
const matchLoser = home_cups < away_cups ? match.home : match.away;
|
const matchLoser = home_cups < away_cups ? match.home : match.away;
|
||||||
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
|
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
|
||||||
|
|
||||||
// winner -> where to send match winner to, loser same
|
|
||||||
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
||||||
|
|
||||||
// reset match check
|
|
||||||
if (winner && winner.reset) {
|
if (winner && winner.reset) {
|
||||||
const awayTeamWon = match.away === matchWinner;
|
const awayTeamWon = match.away === matchWinner;
|
||||||
|
|
||||||
if (!awayTeamWon) {
|
if (!awayTeamWon) {
|
||||||
// Reset match is not necessary
|
|
||||||
logger.info("Deleting reset match", {
|
logger.info("Deleting reset match", {
|
||||||
resetMatchId: winner.id,
|
resetMatchId: winner.id,
|
||||||
currentMatchId: match.id,
|
currentMatchId: match.id,
|
||||||
@@ -214,7 +403,6 @@ export const endMatch = createServerFn()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// advance bracket
|
|
||||||
if (winner) {
|
if (winner) {
|
||||||
await pbAdmin.updateMatch(winner.id, {
|
await pbAdmin.updateMatch(winner.id, {
|
||||||
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TeamInfo, Team } from "../teams/types";
|
|||||||
import { TournamentInfo } from "../tournaments/types";
|
import { TournamentInfo } from "../tournaments/types";
|
||||||
|
|
||||||
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
||||||
|
export type MatchType = "group_stage" | "knockout" | "winners" | "losers" | "bracket";
|
||||||
|
|
||||||
export interface Match {
|
export interface Match {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +30,8 @@ export interface Match {
|
|||||||
updated: string;
|
updated: string;
|
||||||
home_seed?: number;
|
home_seed?: number;
|
||||||
away_seed?: number;
|
away_seed?: number;
|
||||||
|
match_type?: MatchType;
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const matchInputSchema = z.object({
|
export const matchInputSchema = z.object({
|
||||||
@@ -53,6 +56,8 @@ export const matchInputSchema = z.object({
|
|||||||
away: z.string().min(1).optional(),
|
away: z.string().min(1).optional(),
|
||||||
home_seed: z.number().int().min(1).optional(),
|
home_seed: z.number().int().min(1).optional(),
|
||||||
away_seed: z.number().int().min(1).optional(),
|
away_seed: z.number().int().min(1).optional(),
|
||||||
|
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
|
||||||
|
group: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MatchInput = z.infer<typeof matchInputSchema>;
|
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||||
|
|||||||
59
src/features/teams/components/regional-team-card.tsx
Normal file
59
src/features/teams/components/regional-team-card.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Title
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useTeam } from "../queries";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
|
||||||
|
interface RegionalTeamCardProps {
|
||||||
|
teamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RegionalTeamCard = ({ teamId }: RegionalTeamCardProps) => {
|
||||||
|
const { data: team, error } = useTeam(teamId);
|
||||||
|
|
||||||
|
if (error || !team) {
|
||||||
|
return (
|
||||||
|
<Paper p="sm" withBorder radius="md">
|
||||||
|
<Text c="red" ta="center" size="sm">
|
||||||
|
Failed to load team
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
shadow="xs"
|
||||||
|
p="xs"
|
||||||
|
>
|
||||||
|
<Group gap="md" align="center">
|
||||||
|
<TeamAvatar
|
||||||
|
team={team}
|
||||||
|
size={40}
|
||||||
|
radius="md"
|
||||||
|
isRegional={true}
|
||||||
|
style={{
|
||||||
|
backgroundColor: team.primary_color || undefined,
|
||||||
|
color: team.accent_color || undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Title order={5} lineClamp={1}>
|
||||||
|
{team.name}
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
|
{team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegionalTeamCard;
|
||||||
@@ -49,9 +49,10 @@ interface TeamListProps {
|
|||||||
teams: TeamInfo[];
|
teams: TeamInfo[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onTeamClick?: (teamId: string) => void;
|
onTeamClick?: (teamId: string) => void;
|
||||||
|
isRegional?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
const TeamList = ({ teams, loading = false, onTeamClick, isRegional }: TeamListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
@@ -92,6 +93,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
|||||||
team={team}
|
team={team}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
size={40}
|
size={40}
|
||||||
|
isRegional={isRegional}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const teamInputSchema = z
|
|||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
|
private: z.boolean().optional(),
|
||||||
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
|
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
162
src/features/tournaments/components/edit-enrolled-players.tsx
Normal file
162
src/features/tournaments/components/edit-enrolled-players.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
ActionIcon,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { TrashIcon } from "@phosphor-icons/react";
|
||||||
|
import { useCallback, memo } from "react";
|
||||||
|
import { useFreeAgents } from "../queries";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import { PlayerInfo, Player } from "@/features/players/types";
|
||||||
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
|
import { usePlayers } from "@/features/players/queries";
|
||||||
|
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
|
||||||
|
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
|
||||||
|
|
||||||
|
interface EditEnrolledPlayersProps {
|
||||||
|
tournamentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerItemProps {
|
||||||
|
player: PlayerInfo;
|
||||||
|
onRemove: (playerId: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
|
<PlayerAvatar
|
||||||
|
name={`${player.first_name} ${player.last_name}`}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => onRemove(player.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<TrashIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
|
||||||
|
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
|
||||||
|
const { data: allPlayers = [] } = usePlayers();
|
||||||
|
|
||||||
|
const { mutate: removeFreeAgent, isPending: isRemoving } = useAdminUnenrollPlayer();
|
||||||
|
const { mutate: enrollPlayer, isPending: isEnrolling } = useAdminEnrollPlayer();
|
||||||
|
|
||||||
|
const handleRemovePlayer = useCallback(
|
||||||
|
(playerId: string) => {
|
||||||
|
removeFreeAgent({ tournamentId, playerId });
|
||||||
|
},
|
||||||
|
[removeFreeAgent, tournamentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEnrollPlayer = useCallback(
|
||||||
|
(option: TypeaheadOption<Player>) => {
|
||||||
|
enrollPlayer({ tournamentId, playerId: option.data.id });
|
||||||
|
},
|
||||||
|
[enrollPlayer, tournamentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrolledPlayers = freeAgents.map(agent => agent.player).filter((p): p is PlayerInfo => p !== undefined);
|
||||||
|
const enrolledPlayerIds = new Set(enrolledPlayers.map(p => p.id));
|
||||||
|
const hasEnrolledPlayers = enrolledPlayers.length > 0;
|
||||||
|
|
||||||
|
const searchPlayers = async (query: string): Promise<TypeaheadOption<Player>[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const filtered = allPlayers.filter((player: Player) => {
|
||||||
|
const fullName = `${player.first_name} ${player.last_name}`.toLowerCase();
|
||||||
|
return fullName.includes(query.toLowerCase()) && !enrolledPlayerIds.has(player.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.map((player: Player) => ({
|
||||||
|
id: player.id,
|
||||||
|
data: player
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPlayerOption = (option: TypeaheadOption<Player>) => {
|
||||||
|
const player = option.data;
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm" align="center">
|
||||||
|
<PlayerAvatar
|
||||||
|
name={`${player.first_name} ${player.last_name}`}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPlayer = (option: TypeaheadOption<Player>) => {
|
||||||
|
return `${option.data.first_name} ${option.data.last_name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" w="100%">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Add Player
|
||||||
|
</Text>
|
||||||
|
<Typeahead
|
||||||
|
placeholder="Search for players to enroll..."
|
||||||
|
onSelect={handleEnrollPlayer}
|
||||||
|
searchFn={searchPlayers}
|
||||||
|
renderOption={renderPlayerOption}
|
||||||
|
format={formatPlayer}
|
||||||
|
disabled={isEnrolling}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Enrolled Players
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{enrolledPlayers.length} players
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : !hasEnrolledPlayers ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||||
|
No players enrolled yet
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs" w="100%">
|
||||||
|
{enrolledPlayers.map((player) => (
|
||||||
|
<PlayerItem
|
||||||
|
key={player.id}
|
||||||
|
player={player}
|
||||||
|
onRemove={handleRemovePlayer}
|
||||||
|
disabled={isRemoving}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditEnrolledPlayers;
|
||||||
201
src/features/tournaments/components/group-match-card.tsx
Normal file
201
src/features/tournaments/components/group-match-card.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { Card, Group, Stack, Text, ActionIcon, Indicator, Flex, Box } from "@mantine/core";
|
||||||
|
import { PlayIcon, PencilIcon } from "@phosphor-icons/react";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { endMatch, startMatch } from "@/features/matches/server";
|
||||||
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { MatchForm } from "@/features/bracket/components/match-form";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
|
||||||
|
interface GroupMatchCardProps {
|
||||||
|
match: Match;
|
||||||
|
showControls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const editSheet = useSheet();
|
||||||
|
|
||||||
|
const isReady = match.status === "ready";
|
||||||
|
const isStarted = match.status === "started";
|
||||||
|
const isEnded = match.status === "ended";
|
||||||
|
|
||||||
|
const homeWon = isEnded && match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups;
|
||||||
|
const awayWon = isEnded && match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups;
|
||||||
|
|
||||||
|
const start = useServerMutation({
|
||||||
|
mutationFn: startMatch,
|
||||||
|
successMessage: "Match started!",
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = useServerMutation({
|
||||||
|
mutationFn: endMatch,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
|
});
|
||||||
|
editSheet.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
async (data: {
|
||||||
|
home_cups: number;
|
||||||
|
away_cups: number;
|
||||||
|
ot_count: number;
|
||||||
|
}) => {
|
||||||
|
end.mutate({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
matchId: match.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[end, match.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartMatch = () => {
|
||||||
|
start.mutate({ data: match.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const showStartButton = isReady && showControls;
|
||||||
|
const showEditButton = isStarted && showControls;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex direction="row" align="stretch">
|
||||||
|
<Indicator
|
||||||
|
inline
|
||||||
|
processing={isStarted}
|
||||||
|
color="red"
|
||||||
|
size={16}
|
||||||
|
disabled={!isStarted || showEditButton}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<TeamAvatar team={match.home} size={32} radius="sm" isRegional={match.tournament.regional} />
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={homeWon ? 700 : 500}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
lineClamp={1}
|
||||||
|
>
|
||||||
|
{match.home?.name || "TBD"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{isEnded && match.home_cups !== undefined && (
|
||||||
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c={homeWon ? "green" : "dimmed"}
|
||||||
|
style={{ minWidth: 32, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{match.home_cups}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: 'var(--mantine-color-default-border)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<TeamAvatar team={match.away} size={32} radius="sm" isRegional={match.tournament.regional} />
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={awayWon ? 700 : 500}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
lineClamp={1}
|
||||||
|
>
|
||||||
|
{match.away?.name || "TBD"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{isEnded && match.away_cups !== undefined && (
|
||||||
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c={awayWon ? "green" : "dimmed"}
|
||||||
|
style={{ minWidth: 32, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{match.away_cups}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Indicator>
|
||||||
|
|
||||||
|
{showStartButton && (
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
onClick={handleStartMatch}
|
||||||
|
loading={start.isPending}
|
||||||
|
size="md"
|
||||||
|
h="100%"
|
||||||
|
radius="sm"
|
||||||
|
ml={-4}
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayIcon size={16} weight="fill" />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditButton && (
|
||||||
|
<ActionIcon
|
||||||
|
color="blue"
|
||||||
|
onClick={editSheet.open}
|
||||||
|
size="md"
|
||||||
|
h="100%"
|
||||||
|
radius="sm"
|
||||||
|
ml={-4}
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{showControls && (
|
||||||
|
<Sheet title="End Match" opened={editSheet.isOpen} onChange={editSheet.toggle}>
|
||||||
|
<MatchForm
|
||||||
|
match={match}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
isPending={end.isPending}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMatchCard;
|
||||||
50
src/features/tournaments/components/group-preview.tsx
Normal file
50
src/features/tournaments/components/group-preview.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Stack, Text, Card, Group, Box } from "@mantine/core";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
|
||||||
|
interface GroupAssignment {
|
||||||
|
groupIndex: number;
|
||||||
|
groupName: string;
|
||||||
|
teams: TeamInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupPreviewProps {
|
||||||
|
groups: GroupAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupPreview: React.FC<GroupPreviewProps> = ({ groups }) => {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Card key={group.groupIndex} withBorder radius="md" p="md" w="fit-content">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Group {group.groupName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
({group.teams.length} teams)
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
{group.teams.map((team, index) => (
|
||||||
|
<Group key={team.id} gap="sm" align="center" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" w={20} ta="right">
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
<TeamAvatar team={team} size={24} radius="sm" isRegional />
|
||||||
|
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupPreview;
|
||||||
401
src/features/tournaments/components/group-stage-view.tsx
Normal file
401
src/features/tournaments/components/group-stage-view.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
|
||||||
|
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { Group } from "../types";
|
||||||
|
import GroupMatchCard from "./group-match-card";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
|
import { populateKnockoutBracket } from "@/features/matches/server";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
|
interface GroupStageViewProps {
|
||||||
|
groups: Group[];
|
||||||
|
matches: Match[];
|
||||||
|
showControls?: boolean;
|
||||||
|
tournamentId?: string;
|
||||||
|
hasKnockoutBracket?: boolean;
|
||||||
|
isRegional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamStanding {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
team: any;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
cupsFor: number;
|
||||||
|
cupsAgainst: number;
|
||||||
|
cupDifference: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||||
|
groups,
|
||||||
|
matches,
|
||||||
|
showControls,
|
||||||
|
tournamentId,
|
||||||
|
hasKnockoutBracket,
|
||||||
|
isRegional,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const populateKnockoutMutation = useServerMutation({
|
||||||
|
mutationFn: populateKnockoutBracket,
|
||||||
|
successMessage: "Knockout bracket populated successfully!",
|
||||||
|
onSuccess: () => {
|
||||||
|
if (tournamentId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allGroupMatchesCompleted = useMemo(() => {
|
||||||
|
const groupMatches = matches.filter((match) => match.round === -1);
|
||||||
|
if (groupMatches.length === 0) return false;
|
||||||
|
return groupMatches.every((match) => match.status === "ended");
|
||||||
|
}, [matches]);
|
||||||
|
|
||||||
|
const handlePopulateKnockout = () => {
|
||||||
|
if (!tournamentId) return;
|
||||||
|
populateKnockoutMutation.mutate({ data: { tournamentId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
||||||
|
if (matches.length <= 1) return matches;
|
||||||
|
|
||||||
|
const ordered: Match[] = [];
|
||||||
|
const remaining = [...matches];
|
||||||
|
|
||||||
|
ordered.push(remaining.shift()!);
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const lastMatch = ordered[ordered.length - 1];
|
||||||
|
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
|
||||||
|
|
||||||
|
let bestMatchIndex = remaining.findIndex((match) => {
|
||||||
|
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
|
||||||
|
for (const teamId of currentTeams) {
|
||||||
|
if (lastTeams.has(teamId)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bestMatchIndex === -1) {
|
||||||
|
bestMatchIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesByGroup = useMemo(() => {
|
||||||
|
const map = new Map<string, Match[]>();
|
||||||
|
|
||||||
|
matches
|
||||||
|
.filter((match) => match.round === -1 && match.group)
|
||||||
|
.forEach((match) => {
|
||||||
|
if (!map.has(match.group!)) {
|
||||||
|
map.set(match.group!, []);
|
||||||
|
}
|
||||||
|
map.get(match.group!)!.push(match);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.forEach((groupMatches, groupId) => {
|
||||||
|
map.set(groupId, orderMatchesWithSpacing(groupMatches));
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [matches]);
|
||||||
|
|
||||||
|
const sortedGroups = useMemo(() => {
|
||||||
|
return [...groups].sort((a, b) => a.order - b.order);
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
const toggleTeams = (groupId: string) => {
|
||||||
|
setExpandedTeams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[groupId]: !prev[groupId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTeamStandings = (groupId: string, teams: any[]): TeamStanding[] => {
|
||||||
|
const groupMatches = matchesByGroup.get(groupId) || [];
|
||||||
|
const standings: Map<string, TeamStanding> = new Map();
|
||||||
|
|
||||||
|
teams.forEach((team) => {
|
||||||
|
standings.set(team.id, {
|
||||||
|
teamId: team.id,
|
||||||
|
teamName: team.name,
|
||||||
|
team: team,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
cupsFor: 0,
|
||||||
|
cupsAgainst: 0,
|
||||||
|
cupDifference: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedMatches = groupMatches.filter((match) => match.status === "ended");
|
||||||
|
|
||||||
|
completedMatches.forEach((match) => {
|
||||||
|
const homeId = match.home?.id;
|
||||||
|
const awayId = match.away?.id;
|
||||||
|
|
||||||
|
if (!homeId || !awayId) return;
|
||||||
|
|
||||||
|
const homeStanding = standings.get(homeId);
|
||||||
|
const awayStanding = standings.get(awayId);
|
||||||
|
|
||||||
|
if (!homeStanding || !awayStanding) return;
|
||||||
|
|
||||||
|
const homeCups = match.home_cups || 0;
|
||||||
|
const awayCups = match.away_cups || 0;
|
||||||
|
|
||||||
|
homeStanding.cupsFor += homeCups;
|
||||||
|
homeStanding.cupsAgainst += awayCups;
|
||||||
|
awayStanding.cupsFor += awayCups;
|
||||||
|
awayStanding.cupsAgainst += homeCups;
|
||||||
|
|
||||||
|
homeStanding.cupDifference += homeCups - awayCups;
|
||||||
|
awayStanding.cupDifference += awayCups - homeCups;
|
||||||
|
|
||||||
|
if (homeCups > awayCups) {
|
||||||
|
homeStanding.wins++;
|
||||||
|
awayStanding.losses++;
|
||||||
|
} else if (awayCups > homeCups) {
|
||||||
|
awayStanding.wins++;
|
||||||
|
homeStanding.losses++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const h2hRecords = new Map<string, Map<string, { wins: number; cupDiff: number }>>();
|
||||||
|
|
||||||
|
completedMatches.forEach((match) => {
|
||||||
|
const homeId = match.home?.id;
|
||||||
|
const awayId = match.away?.id;
|
||||||
|
|
||||||
|
if (!homeId || !awayId) return;
|
||||||
|
|
||||||
|
if (!h2hRecords.has(homeId)) {
|
||||||
|
h2hRecords.set(homeId, new Map());
|
||||||
|
}
|
||||||
|
if (!h2hRecords.has(awayId)) {
|
||||||
|
h2hRecords.set(awayId, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeH2H = h2hRecords.get(homeId)!;
|
||||||
|
const awayH2H = h2hRecords.get(awayId)!;
|
||||||
|
|
||||||
|
if (!homeH2H.has(awayId)) {
|
||||||
|
homeH2H.set(awayId, { wins: 0, cupDiff: 0 });
|
||||||
|
}
|
||||||
|
if (!awayH2H.has(homeId)) {
|
||||||
|
awayH2H.set(homeId, { wins: 0, cupDiff: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeRecord = homeH2H.get(awayId)!;
|
||||||
|
const awayRecord = awayH2H.get(homeId)!;
|
||||||
|
|
||||||
|
const homeCups = match.home_cups || 0;
|
||||||
|
const awayCups = match.away_cups || 0;
|
||||||
|
|
||||||
|
const cupDiff = homeCups - awayCups;
|
||||||
|
homeRecord.cupDiff += cupDiff;
|
||||||
|
awayRecord.cupDiff -= cupDiff;
|
||||||
|
|
||||||
|
if (homeCups > awayCups) {
|
||||||
|
homeRecord.wins++;
|
||||||
|
} else if (awayCups > homeCups) {
|
||||||
|
awayRecord.wins++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(standings.values()).sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
|
||||||
|
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
|
||||||
|
|
||||||
|
if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
|
||||||
|
|
||||||
|
const aH2H = h2hRecords.get(a.teamId);
|
||||||
|
const bH2H = h2hRecords.get(b.teamId);
|
||||||
|
|
||||||
|
if (aH2H && bH2H) {
|
||||||
|
const aVsB = aH2H.get(b.teamId);
|
||||||
|
const bVsA = bH2H.get(a.teamId);
|
||||||
|
|
||||||
|
if (aVsB && bVsA) {
|
||||||
|
if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins;
|
||||||
|
|
||||||
|
if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.teamId.localeCompare(b.teamId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sortedGroups.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
No groups have been created yet
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
{showGenerateKnockoutButton && (
|
||||||
|
<Alert color="blue" title="Group Stage Complete" icon={<CaretCircleUpIcon size={20} />}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm">All group matches are finished! Populate the knockout bracket to advance qualified teams.</Text>
|
||||||
|
<Button
|
||||||
|
onClick={handlePopulateKnockout}
|
||||||
|
loading={populateKnockoutMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Populate Knockout Bracket
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||||
|
<Tabs.List mb="md" grow>
|
||||||
|
{sortedGroups.map((group) => {
|
||||||
|
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||||
|
const completedMatches = groupMatches.filter((m) => m.status === "ended").length;
|
||||||
|
const totalMatches = groupMatches.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={group.id}
|
||||||
|
value={group.id}
|
||||||
|
style={{
|
||||||
|
padding: '12px 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MantineGroup gap="xs" justify="center" wrap="nowrap">
|
||||||
|
<Text fw={600} size="sm">Group {group.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed" style={{ opacity: 0.7 }}>
|
||||||
|
({completedMatches}/{totalMatches})
|
||||||
|
</Text>
|
||||||
|
</MantineGroup>
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{sortedGroups.map((group) => {
|
||||||
|
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||||
|
const standings = getTeamStandings(group.id, group.teams || []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs.Panel key={group.id} value={group.id}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder radius="md" p={0}>
|
||||||
|
<MantineGroup
|
||||||
|
justify="space-between"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||||
|
}}
|
||||||
|
onClick={() => toggleTeams(group.id)}
|
||||||
|
>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Standings ({standings.length})
|
||||||
|
</Text>
|
||||||
|
<ActionIcon variant="subtle" size="sm">
|
||||||
|
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</MantineGroup>
|
||||||
|
<Collapse in={expandedTeams[group.id]}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{standings.length > 0 ? (
|
||||||
|
standings.map((standing, index) => (
|
||||||
|
<MantineGroup
|
||||||
|
key={standing.teamId}
|
||||||
|
gap="sm"
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{
|
||||||
|
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
|
||||||
|
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
|
||||||
|
{standing.teamName}
|
||||||
|
</Text>
|
||||||
|
<MantineGroup gap="xs" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
|
||||||
|
{standing.wins}-{standing.losses}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||||
|
fw={600}
|
||||||
|
miw={30}
|
||||||
|
ta="center"
|
||||||
|
>
|
||||||
|
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
|
||||||
|
{standing.cupsFor}/{standing.cupsAgainst}
|
||||||
|
</Text>
|
||||||
|
</MantineGroup>
|
||||||
|
</MantineGroup>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
No teams assigned
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{groupMatches.length === 0 ? (
|
||||||
|
<Card withBorder radius="md" p="xl">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
No matches scheduled
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid
|
||||||
|
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||||
|
spacing="md"
|
||||||
|
>
|
||||||
|
{groupMatches.map((match) => (
|
||||||
|
<GroupMatchCard
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
showControls={showControls}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupStageView;
|
||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
UsersThreeIcon,
|
UsersThreeIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
ShuffleIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||||
|
import EditEnrolledPlayers from "./edit-enrolled-players";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { RichTextEditor } from "@/components/rich-text-editor";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import EditRules from "./edit-rules";
|
import EditRules from "./edit-rules";
|
||||||
|
|
||||||
@@ -61,11 +62,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
Icon={UsersThreeIcon}
|
Icon={UsersThreeIcon}
|
||||||
onClick={openEditTeams}
|
onClick={openEditTeams}
|
||||||
/>
|
/>
|
||||||
<ListLink
|
{tournament.regional && (
|
||||||
label="Manage Team Songs/Logos"
|
<ListLink
|
||||||
Icon={UsersIcon}
|
label="Assign Partners"
|
||||||
to={`/admin/tournaments/${tournamentId}/teams`}
|
Icon={ShuffleIcon}
|
||||||
/>
|
to={`/admin/tournaments/${tournamentId}/assign-partners`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!tournament.regional && (
|
||||||
|
<ListLink
|
||||||
|
label="Manage Team Songs/Logos"
|
||||||
|
Icon={UsersIcon}
|
||||||
|
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Run Tournament"
|
label="Run Tournament"
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
@@ -102,11 +112,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Sheet
|
<Sheet
|
||||||
title="Edit Enrolled Teams"
|
title={tournament.regional === true ? "Manage Enrollments" : "Edit Enrolled Teams"}
|
||||||
opened={editTeamsOpened}
|
opened={editTeamsOpened}
|
||||||
onChange={closeEditTeams}
|
onChange={closeEditTeams}
|
||||||
>
|
>
|
||||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
{tournament.regional === true ? (
|
||||||
|
<EditEnrolledPlayers tournamentId={tournamentId} />
|
||||||
|
) : (
|
||||||
|
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||||
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
content: <>
|
content: <>
|
||||||
<TeamList teams={tournament.teams || []} />
|
<TeamList teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
], [tournament]);
|
], [tournament]);
|
||||||
|
|||||||
296
src/features/tournaments/components/setup-group-stage.tsx
Normal file
296
src/features/tournaments/components/setup-group-stage.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
LoadingOverlay,
|
||||||
|
Alert,
|
||||||
|
Title,
|
||||||
|
Divider,
|
||||||
|
Box,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
|
import { generateGroupStage } from "../server";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
import {
|
||||||
|
calculateGroupConfigurations,
|
||||||
|
assignTeamsToGroups,
|
||||||
|
getGroupName,
|
||||||
|
GroupConfigOption,
|
||||||
|
} from "../utils/group-config";
|
||||||
|
import GroupPreview from "./group-preview";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
import brackets from "@/features/bracket/utils";
|
||||||
|
import { Bracket } from "@/features/bracket/components/bracket";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { generateSingleEliminationBracket } from "../utils/bracket-generator";
|
||||||
|
|
||||||
|
interface SetupGroupStageProps {
|
||||||
|
tournamentId: string;
|
||||||
|
teams: TeamInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
||||||
|
tournamentId,
|
||||||
|
teams,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [selectedConfigIndex, setSelectedConfigIndex] = useState<string>("0");
|
||||||
|
const [seed, setSeed] = useState<number>(Date.now());
|
||||||
|
|
||||||
|
const configurations = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return calculateGroupConfigurations(teams.length);
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [teams.length]);
|
||||||
|
|
||||||
|
const selectedConfig: GroupConfigOption | null = useMemo(() => {
|
||||||
|
const index = parseInt(selectedConfigIndex);
|
||||||
|
return configurations[index] || null;
|
||||||
|
}, [selectedConfigIndex, configurations]);
|
||||||
|
|
||||||
|
const groupAssignments = useMemo(() => {
|
||||||
|
if (!selectedConfig) return [];
|
||||||
|
|
||||||
|
const teamIds = teams.map((t) => t.id);
|
||||||
|
const assignments = assignTeamsToGroups(teamIds, selectedConfig, seed);
|
||||||
|
|
||||||
|
return assignments.map((teamIds, index) => ({
|
||||||
|
groupIndex: index,
|
||||||
|
groupName: getGroupName(index),
|
||||||
|
teams: teamIds.map((id) => teams.find((t) => t.id === id)!).filter(Boolean),
|
||||||
|
teamIds,
|
||||||
|
}));
|
||||||
|
}, [selectedConfig, teams, seed]);
|
||||||
|
|
||||||
|
const knockoutTeamCount = useMemo(() => {
|
||||||
|
if (!selectedConfig) return 0;
|
||||||
|
return selectedConfig.num_groups * selectedConfig.advance_per_group;
|
||||||
|
}, [selectedConfig]);
|
||||||
|
|
||||||
|
const bracketPreview = useMemo(() => {
|
||||||
|
if (!knockoutTeamCount || !selectedConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bracketTemplate: any;
|
||||||
|
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
||||||
|
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
||||||
|
const seedLabels: Record<number, string> = {};
|
||||||
|
|
||||||
|
const totalTeams = selectedConfig.num_groups * selectedConfig.advance_per_group;
|
||||||
|
let seedIndex = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalTeams / 2; i++) {
|
||||||
|
const group1 = i % selectedConfig.num_groups;
|
||||||
|
const rankIndex1 = Math.floor(i / selectedConfig.num_groups);
|
||||||
|
const rank1 = rankIndex1 + 1;
|
||||||
|
const groupName1 = groupNames[group1] || `Group ${group1 + 1}`;
|
||||||
|
const rankSuffix1 = rank1 === 1 ? '1st' : rank1 === 2 ? '2nd' : rank1 === 3 ? '3rd' : `${rank1}th`;
|
||||||
|
seedLabels[seedIndex++] = `${groupName1} ${rankSuffix1}`;
|
||||||
|
|
||||||
|
const group2 = (i + 1) % selectedConfig.num_groups;
|
||||||
|
const rankIndex2 = selectedConfig.advance_per_group - 1 - rankIndex1;
|
||||||
|
const rank2 = rankIndex2 + 1;
|
||||||
|
const groupName2 = groupNames[group2] || `Group ${group2 + 1}`;
|
||||||
|
const rankSuffix2 = rank2 === 1 ? '1st' : rank2 === 2 ? '2nd' : rank2 === 3 ? '3rd' : `${rank2}th`;
|
||||||
|
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordersMap: Record<number, number> = {};
|
||||||
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
|
round.forEach((match: any) => {
|
||||||
|
ordersMap[match.lid] = match.order;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) =>
|
||||||
|
round.map((match: any) => {
|
||||||
|
const matchData: any = {
|
||||||
|
...match,
|
||||||
|
id: `preview-${match.lid}`,
|
||||||
|
home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1,
|
||||||
|
away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1,
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
status: "tbd" as const,
|
||||||
|
tournament: { id: "", name: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (roundIndex === 0) {
|
||||||
|
matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null;
|
||||||
|
matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null;
|
||||||
|
} else {
|
||||||
|
matchData.home = null;
|
||||||
|
matchData.away = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchData;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { matches: placeholderMatches, orders: ordersMap };
|
||||||
|
}, [knockoutTeamCount, selectedConfig]);
|
||||||
|
|
||||||
|
const generateGroups = useServerMutation({
|
||||||
|
mutationFn: generateGroupStage,
|
||||||
|
successMessage: "Group stage generated successfully!",
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(tournamentId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerateGroups = () => {
|
||||||
|
if (!selectedConfig) return;
|
||||||
|
|
||||||
|
generateGroups.mutate({
|
||||||
|
data: {
|
||||||
|
tournamentId,
|
||||||
|
groupConfig: {
|
||||||
|
num_groups: selectedConfig.num_groups,
|
||||||
|
teams_per_group: selectedConfig.teams_per_group,
|
||||||
|
advance_per_group: selectedConfig.advance_per_group,
|
||||||
|
matches_guaranteed: selectedConfig.matches_guaranteed,
|
||||||
|
seeding_method: selectedConfig.seeding_method,
|
||||||
|
},
|
||||||
|
teamAssignments: groupAssignments.map((g) => ({
|
||||||
|
groupIndex: g.groupIndex,
|
||||||
|
groupName: g.groupName,
|
||||||
|
teamIds: g.teamIds,
|
||||||
|
})),
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShuffle = () => {
|
||||||
|
setSeed(Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (configurations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" title="Cannot create groups" icon={<InfoIcon />}>
|
||||||
|
Need at least 4 teams to create a group stage format. Current team count: {teams.length}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: "2rem", alignItems: "flex-start" }}>
|
||||||
|
<Stack gap="lg" style={{ flexShrink: 0, width: 400 }}>
|
||||||
|
<Stack gap={0} pos="relative">
|
||||||
|
<LoadingOverlay visible={generateGroups.isPending} />
|
||||||
|
|
||||||
|
<Group gap="xs" p="md" pb="sm" align="center">
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
Group Stage Setup
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ml="auto">
|
||||||
|
{teams.length} teams
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="md" p="md" pt={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Group Configuration
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={selectedConfigIndex}
|
||||||
|
onChange={(value) => setSelectedConfigIndex(value || "0")}
|
||||||
|
data={configurations.map((config, index) => ({
|
||||||
|
value: index.toString(),
|
||||||
|
label: config.description,
|
||||||
|
}))}
|
||||||
|
styles={{
|
||||||
|
dropdown: { maxHeight: 300, overflowY: "auto" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{selectedConfig && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{selectedConfig.total_group_matches} total group stage matches
|
||||||
|
</Text>
|
||||||
|
{selectedConfig.wildcards_needed > 0 && (
|
||||||
|
<Text size="xs" c="yellow">
|
||||||
|
⚠ {selectedConfig.wildcards_needed} wildcard spot{selectedConfig.wildcards_needed > 1 ? 's' : ''} needed for knockout bracket
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" variant="light" onClick={handleShuffle}>
|
||||||
|
Shuffle Groups
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateGroups}
|
||||||
|
loading={generateGroups.isPending}
|
||||||
|
disabled={!selectedConfig}
|
||||||
|
>
|
||||||
|
Confirm Setup
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
||||||
|
<Stack gap="xl">
|
||||||
|
{groupAssignments.length > 0 && (
|
||||||
|
<GroupPreview groups={groupAssignments} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bracketPreview && knockoutTeamCount > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Divider mb="lg" />
|
||||||
|
<Title order={3} ta="center" mb="md">
|
||||||
|
Knockout Bracket Preview ({knockoutTeamCount} Teams)
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" mb="lg">
|
||||||
|
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 8px 8px",
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bracket
|
||||||
|
rounds={bracketPreview.matches}
|
||||||
|
orders={bracketPreview.orders}
|
||||||
|
showControls={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SetupGroupStage;
|
||||||
@@ -4,7 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||||
import { Carousel } from "@mantine/carousel";
|
import { Carousel } from "@mantine/carousel";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||||
import MatchCard from "@/features/matches/components/match-card";
|
import MatchCard from "@/features/matches/components/match-card";
|
||||||
@@ -37,6 +37,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
return finalsMatch?.status === 'ended';
|
return finalsMatch?.status === 'ended';
|
||||||
}, [tournament.matches]);
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const hasGroupStage = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
@@ -83,12 +87,19 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
Icon={UsersIcon}
|
Icon={UsersIcon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasGroupStage && (
|
||||||
|
<ListLink
|
||||||
|
label={`View Groups`}
|
||||||
|
to={`/tournaments/${tournament.id}/groups`}
|
||||||
|
Icon={ListDashes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ListLink
|
<ListLink
|
||||||
label={`View Bracket`}
|
label={`View Bracket`}
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
/>
|
/>
|
||||||
<TeamListButton teams={tournament.teams || []} />
|
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
<RulesListButton tournamentId={tournament.id} />
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Card, Group, Stack, Text, Avatar } from "@mantine/core";
|
||||||
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
|
||||||
|
interface TeamAssignment {
|
||||||
|
player1: PlayerInfo;
|
||||||
|
player2: PlayerInfo;
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamAssignmentPreviewProps {
|
||||||
|
assignments: TeamAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamAssignmentPreview: React.FC<TeamAssignmentPreviewProps> = ({ assignments }) => {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{assignments.map((assignment, index) => (
|
||||||
|
<Card key={index} withBorder radius="md" p="md">
|
||||||
|
<Group gap="md" align="center" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600} c="dimmed" w={40}>
|
||||||
|
#{index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="sm" style={{ flex: 1 }} align="center">
|
||||||
|
<PlayerAvatar name={`${assignment.player1.first_name} ${assignment.player1.last_name}`} size={32} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{assignment.player1.first_name} {assignment.player1.last_name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="lg" c="dimmed">
|
||||||
|
&
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="sm" style={{ flex: 1 }} align="center">
|
||||||
|
<PlayerAvatar name={`${assignment.player2.first_name} ${assignment.player2.last_name}`} size={32} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{assignment.player2.first_name} {assignment.player2.last_name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamAssignmentPreview;
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import { CrownIcon, TreeStructureIcon, InfoIcon } from "@phosphor-icons/react";
|
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
||||||
import Avatar from "@/components/avatar";
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { Podium } from "./podium";
|
import { Podium } from "./podium";
|
||||||
|
|
||||||
@@ -33,6 +33,10 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
[nonByeMatches]
|
[nonByeMatches]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasGroupStage = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
const sortedTeamStats = useMemo(() => {
|
const sortedTeamStats = useMemo(() => {
|
||||||
return [...(tournament.team_stats || [])].sort((a, b) => {
|
return [...(tournament.team_stats || [])].sort((a, b) => {
|
||||||
if (b.wins !== a.wins) {
|
if (b.wins !== a.wins) {
|
||||||
@@ -68,6 +72,8 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
<Text px="md" size="lg" fw={600}>Results</Text>
|
<Text px="md" size="lg" fw={600}>Results</Text>
|
||||||
<Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
|
<Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
|
||||||
{teamStatsWithCalculations.map((stat, index) => {
|
{teamStatsWithCalculations.map((stat, index) => {
|
||||||
|
const team = tournament.teams?.find(t => t.id === stat.team_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={stat.id}>
|
<Box key={stat.id}>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -77,7 +83,11 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
>
|
>
|
||||||
<Group justify="space-between" align="center" w="100%">
|
<Group justify="space-between" align="center" w="100%">
|
||||||
<Group gap="sm" align="center">
|
<Group gap="sm" align="center">
|
||||||
<Avatar name={stat.team_name} size={40} radius="sm" />
|
{team ? (
|
||||||
|
<TeamAvatar team={team} size={40} radius="sm" isRegional={tournament.regional} />
|
||||||
|
) : (
|
||||||
|
<TeamAvatar team={{ id: stat.team_id, name: stat.team_name, players: [] } as any} size={40} radius="sm" isRegional={tournament.regional} />
|
||||||
|
)}
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Group gap='xs'>
|
<Group gap='xs'>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -157,12 +167,19 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{tournament.regional && (
|
{tournament.regional && !hasGroupStage && (
|
||||||
<Alert px="md" variant="light" title="Regional Tournament" icon={<InfoIcon size={16} />}>
|
<Alert px="md" variant="light" title="Regional Tournament" icon={<InfoIcon size={16} />}>
|
||||||
Regional tournaments are a work in progress. Some features might not work as expected.
|
Earlier regional formats aren't supported in the app and order of matches or displayed winners may be unreliable.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{!tournament.regional && <Podium tournament={tournament} />}
|
{!tournament.regional && <Podium tournament={tournament} />}
|
||||||
|
{hasGroupStage && (
|
||||||
|
<ListLink
|
||||||
|
label={`View Groups`}
|
||||||
|
to={`/tournaments/${tournament.id}/groups`}
|
||||||
|
Icon={ListDashes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ListLink
|
<ListLink
|
||||||
label={`View Bracket`}
|
label={`View Bracket`}
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import { useSheet } from "@/hooks/use-sheet";
|
|||||||
import { Stack, Text } from "@mantine/core";
|
import { Stack, Text } from "@mantine/core";
|
||||||
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
|
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
|
||||||
|
|
||||||
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
const { user, phone } = useAuth();
|
const { user, phone } = useAuth();
|
||||||
|
|
||||||
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
|
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(isRegional);
|
||||||
const handleEnroll = () => {
|
const handleEnroll = () => {
|
||||||
console.log('enrolling...')
|
|
||||||
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
|
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toggle();
|
toggle();
|
||||||
@@ -22,21 +21,31 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="subtle" size="sm" onClick={open}>
|
<Button variant="subtle" size="sm" onClick={open}>
|
||||||
Enroll As Free Agent
|
{isRegional ? "Enroll" : "Enroll As Free Agent"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
<Sheet title={isRegional ? "Enrollment" : "Free Agent Enrollment"} opened={isOpen} onChange={toggle}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="md">
|
{isRegional ? (
|
||||||
Enrolling as a free agent adds you to a pool of players looking for teammates.
|
<>
|
||||||
</Text>
|
<Text size="md">
|
||||||
<Text size="sm" c='dimmed'>
|
Enroll in this regional tournament to be assigned a random partner.
|
||||||
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
|
</Text>
|
||||||
</Text>
|
</>
|
||||||
<Text size="xs" c="dimmed">
|
) : (
|
||||||
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
|
<>
|
||||||
</Text>
|
<Text size="md">
|
||||||
<Button onClick={handleEnroll}>Confirm</Button>
|
Enrolling as a free agent adds you to a pool of players looking for teammates.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c='dimmed'>
|
||||||
|
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleEnroll} loading={isEnrolling}>Confirm</Button>
|
||||||
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
|
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
|
||||||
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
|
import { PhoneIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useFreeAgents } from "../../queries";
|
import { useFreeAgents } from "../../queries";
|
||||||
import UnenrollFreeAgent from "./unenroll-free-agent";
|
import UnenrollFreeAgent from "./unenroll-free-agent";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
|
||||||
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }> = ({
|
||||||
tournamentId
|
tournamentId,
|
||||||
|
isRegional
|
||||||
}) => {
|
}) => {
|
||||||
const { data: freeAgents } = useFreeAgents(tournamentId);
|
const { data: freeAgents } = useFreeAgents(tournamentId);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const copyToClipboard = async (phone: string) => {
|
const copyToClipboard = async (phone: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -38,33 +42,66 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isRegional) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="md" align="center">
|
||||||
|
<PlayerAvatar name={`${user?.first_name} ${user?.last_name}`} size={48} />
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{user?.first_name} {user?.last_name}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} align="center">
|
||||||
|
<CheckCircleIcon size={14} weight="fill" color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="xs" c="green" fw={500}>
|
||||||
|
Enrolled
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
Partners will be randomly assigned when enrollment closes
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="center">
|
<Text size="sm" fw={600} c="green">
|
||||||
<Group gap="xs" align="center">
|
✓ Enrolled as Free Agent
|
||||||
<UserIcon size={16} />
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Enrolled as Free Agent
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
You're on the free agent list. Other free agents looking for teams:
|
Other players looking for teammates:
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{freeAgents.length > 1 ? (
|
{freeAgents.length > 1 ? (
|
||||||
<Card withBorder radius="md" p="sm">
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
|
Free Agents
|
||||||
|
</Text>
|
||||||
|
<Badge variant="light" size="xs" color="blue">
|
||||||
|
{freeAgents.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{freeAgents
|
{freeAgents
|
||||||
.filter(agent => agent.player)
|
.filter(agent => agent.player)
|
||||||
.map((agent) => (
|
.map((agent) => (
|
||||||
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
|
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap" p="xs" style={{ borderRadius: '8px', backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Text size="sm" fw={500} truncate>
|
||||||
<Text size="sm" fw={500} truncate>
|
{agent.player?.first_name} {agent.player?.last_name}
|
||||||
{agent.player?.first_name} {agent.player?.last_name}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{agent.phone && (
|
{agent.phone && (
|
||||||
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
|
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -87,27 +124,15 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{freeAgents.length > 1 && (
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
size="xs"
|
|
||||||
color="blue"
|
|
||||||
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{freeAgents.length} free agents total
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Card withBorder radius="md" p="sm">
|
<Text size="xs" c="dimmed" py="sm">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
You're the first free agent!
|
||||||
You're the only free agent so far
|
</Text>
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
<UnenrollFreeAgent tournamentId={tournamentId} />
|
|
||||||
|
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import ListButton from "@/components/list-button";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { UserListIcon } from "@phosphor-icons/react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFreeAgents } from "../../queries";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import PlayerList from "@/features/players/components/player-list";
|
||||||
|
import { Player } from "@/features/players/types";
|
||||||
|
|
||||||
|
interface EnrolledPlayersListButtonProps {
|
||||||
|
tournamentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ tournamentId }) => {
|
||||||
|
const { data: freeAgents } = useFreeAgents(tournamentId);
|
||||||
|
const count = useMemo(() => freeAgents.length, [freeAgents]);
|
||||||
|
const { open, isOpen, toggle } = useSheet();
|
||||||
|
|
||||||
|
const players = useMemo(() =>
|
||||||
|
freeAgents.map(agent => agent.player).filter((player): player is Player => player !== undefined),
|
||||||
|
[freeAgents]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListButton
|
||||||
|
label={`View Enrolled Players (${count})`}
|
||||||
|
Icon={UserListIcon}
|
||||||
|
onClick={open}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
|
||||||
|
{count === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No players enrolled yet
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<PlayerList players={players} />
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnrolledPlayersListButton;
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Suspense, useCallback, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import Countdown from "@/components/countdown";
|
import Countdown from "@/components/countdown";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import ListButton from "@/components/list-button";
|
|
||||||
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
||||||
import EnrollTeam from "./enroll-team";
|
import EnrollTeam from "./enroll-team";
|
||||||
import EnrollFreeAgent from "./enroll-free-agent";
|
import EnrollFreeAgent from "./enroll-free-agent";
|
||||||
import TeamListButton from "./team-list-button";
|
import TeamListButton from "./team-list-button";
|
||||||
|
import EnrolledPlayersListButton from "./enrolled-players-list-button";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
|
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
|
||||||
import TeamCard from "@/features/teams/components/team-card";
|
import TeamCard from "@/features/teams/components/team-card";
|
||||||
|
import RegionalTeamCard from "@/features/teams/components/regional-team-card";
|
||||||
import UpdateTeam from "./update-team";
|
import UpdateTeam from "./update-team";
|
||||||
import UnenrollTeam from "./unenroll-team";
|
import UnenrollTeam from "./unenroll-team";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -80,34 +81,52 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
|
|
||||||
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
|
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
|
||||||
<>
|
<>
|
||||||
<EnrollTeam
|
{!tournament.regional && (
|
||||||
|
<>
|
||||||
|
<EnrollTeam
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<Divider my={0} label="or" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<EnrollFreeAgent
|
||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
onSubmit={handleSubmit}
|
isRegional={tournament.regional}
|
||||||
/>
|
/>
|
||||||
<Divider my={0} label="or" />
|
|
||||||
<EnrollFreeAgent tournamentId={tournament.id} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUserEnrolled && (
|
{isUserEnrolled && (
|
||||||
<>
|
<>
|
||||||
<Suspense fallback={<TeamCardSkeleton />}>
|
<Suspense fallback={<TeamCardSkeleton />}>
|
||||||
<TeamCard teamId={userTeam.id} />
|
{tournament.regional === true ? (
|
||||||
|
<RegionalTeamCard teamId={userTeam.id} />
|
||||||
|
) : (
|
||||||
|
<TeamCard teamId={userTeam.id} />
|
||||||
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
|
{tournament.regional !== true && (
|
||||||
{isEnrollmentOpen && (
|
<>
|
||||||
<UnenrollTeam
|
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
|
||||||
tournamentId={tournament.id}
|
{isEnrollmentOpen && (
|
||||||
teamId={userTeam.id}
|
<UnenrollTeam
|
||||||
onSubmit={handleSubmit}
|
tournamentId={tournament.id}
|
||||||
/>
|
teamId={userTeam.id}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{
|
{
|
||||||
isFreeAgent && isEnrollmentOpen && (
|
isFreeAgent && isEnrollmentOpen && (
|
||||||
<EnrolledFreeAgent tournamentId={tournament.id} />
|
<EnrolledFreeAgent
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
isRegional={tournament.regional}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +149,15 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<TeamListButton teams={tournament.teams || []} />
|
{tournament.regional === true ? (
|
||||||
|
(tournament.teams && tournament.teams.length > 0) ? (
|
||||||
|
<TeamListButton teams={tournament.teams} isRegional={true} />
|
||||||
|
) : (
|
||||||
|
<EnrolledPlayersListButton tournamentId={tournament.id} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<TeamListButton teams={tournament.teams || []} isRegional={false} />
|
||||||
|
)}
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
<RulesListButton tournamentId={tournament.id} />
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { useMemo } from "react"
|
|||||||
|
|
||||||
interface TeamListButtonProps {
|
interface TeamListButtonProps {
|
||||||
teams: TeamInfo[]
|
teams: TeamInfo[]
|
||||||
|
isRegional?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamListButton: React.FC<TeamListButtonProps> = ({ teams }) => {
|
const TeamListButton: React.FC<TeamListButtonProps> = ({ teams, isRegional }) => {
|
||||||
const count = useMemo(() => teams.length, [teams]);
|
const count = useMemo(() => teams.length, [teams]);
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
return (
|
return (
|
||||||
@@ -22,7 +23,7 @@ const TeamListButton: React.FC<TeamListButtonProps> = ({ teams }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
|
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
|
||||||
<TeamList teams={teams} />
|
<TeamList teams={teams} isRegional={isRegional} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useSheet } from "@/hooks/use-sheet";
|
|||||||
import { Stack, Text } from "@mantine/core";
|
import { Stack, Text } from "@mantine/core";
|
||||||
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
|
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
|
||||||
|
|
||||||
const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
const UnenrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
|
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent(isRegional);
|
||||||
const handleUnenroll = () => {
|
const handleUnenroll = () => {
|
||||||
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
|
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -20,17 +20,21 @@ const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="subtle" size="sm" onClick={open}>
|
<Button variant="subtle" size="sm" color="red" onClick={open}>
|
||||||
Unenroll
|
Unenroll
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
|
<Sheet title="Unenroll from tournament?" opened={isOpen} onChange={toggle}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="md">
|
<Text size="md">
|
||||||
This will remove you from the free agent list.
|
{isRegional
|
||||||
|
? "This will remove you from the tournament enrollment."
|
||||||
|
: "This will remove you from the free agent list."}
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={handleUnenroll}>Confirm</Button>
|
<Button color="red" onClick={handleUnenroll} loading={isEnrolling}>
|
||||||
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
Confirm Unenrollment
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" onClick={toggle}>Cancel</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
|
|||||||
21
src/features/tournaments/hooks/use-admin-enroll-player.ts
Normal file
21
src/features/tournaments/hooks/use-admin-enroll-player.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { adminEnrollPlayer } from "@/features/tournaments/server";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
|
const useAdminEnrollPlayer = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: { tournamentId: string, playerId: string }) => {
|
||||||
|
return adminEnrollPlayer({ data });
|
||||||
|
},
|
||||||
|
onSuccess: (data, { tournamentId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
|
||||||
|
},
|
||||||
|
successMessage: "Player enrolled successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAdminEnrollPlayer;
|
||||||
21
src/features/tournaments/hooks/use-admin-unenroll-player.ts
Normal file
21
src/features/tournaments/hooks/use-admin-unenroll-player.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { adminUnenrollPlayer } from "@/features/tournaments/server";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
|
const useAdminUnenrollPlayer = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: { tournamentId: string, playerId: string }) => {
|
||||||
|
return adminUnenrollPlayer({ data });
|
||||||
|
},
|
||||||
|
onSuccess: (data, { tournamentId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
|
||||||
|
},
|
||||||
|
successMessage: "Player removed successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAdminUnenrollPlayer;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
|
import { confirmTeamAssignments } from "../server";
|
||||||
|
|
||||||
|
export default function useConfirmTeamAssignments() {
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: confirmTeamAssignments,
|
||||||
|
successMessage: "Teams created successfully!",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
|||||||
import { enrollFreeAgent } from "@/features/tournaments/server";
|
import { enrollFreeAgent } from "@/features/tournaments/server";
|
||||||
import { tournamentKeys } from "../queries";
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
const useEnrollFreeAgent = () => {
|
const useEnrollFreeAgent = (isRegional?: boolean) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useServerMutation({
|
return useServerMutation({
|
||||||
@@ -13,7 +13,7 @@ const useEnrollFreeAgent = () => {
|
|||||||
onSuccess: (data, { tournamentId }) => {
|
onSuccess: (data, { tournamentId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||||
},
|
},
|
||||||
successMessage: 'You\'ve been added as a free agent!',
|
successMessage: isRegional ? "You've enrolled in regionals!" : "You've been added as a free agent!",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
|
import { generateRandomTeams } from "../server";
|
||||||
|
|
||||||
|
export default function useGenerateRandomTeams() {
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: generateRandomTeams,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
|||||||
import { unenrollFreeAgent } from "@/features/tournaments/server";
|
import { unenrollFreeAgent } from "@/features/tournaments/server";
|
||||||
import { tournamentKeys } from "../queries";
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
const useUnenrollFreeAgent = () => {
|
const useUnenrollFreeAgent = (isRegional?: boolean) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useServerMutation({
|
return useServerMutation({
|
||||||
@@ -13,7 +13,7 @@ const useUnenrollFreeAgent = () => {
|
|||||||
onSuccess: (data, { tournamentId }) => {
|
onSuccess: (data, { tournamentId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||||
},
|
},
|
||||||
successMessage: 'You\'ve been removed as a free agent.',
|
successMessage: isRegional ? "You've been removed from regionals!" : "You've been removed as a free agent.",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { tournamentInputSchema } from "@/features/tournaments/types";
|
import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
import { fa } from "zod/v4/locales";
|
import { fa } from "zod/v4/locales";
|
||||||
|
import brackets from "@/features/bracket/utils";
|
||||||
|
import { MatchInput } from "@/features/matches/types";
|
||||||
|
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||||
|
|
||||||
export const listTournaments = createServerFn()
|
export const listTournaments = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
@@ -129,3 +132,707 @@ export const unenrollFreeAgent = createServerFn()
|
|||||||
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const generateRandomTeams = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
seed: z.number().optional()
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
||||||
|
|
||||||
|
if (freeAgents.length < 2) {
|
||||||
|
throw new Error("Need at least 2 players to create teams");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeAgents.length % 2 !== 0) {
|
||||||
|
throw new Error("Need an even number of players to create teams");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFreeAgents = Array.from(
|
||||||
|
new Map(
|
||||||
|
freeAgents
|
||||||
|
.filter(fa => fa.player?.id)
|
||||||
|
.map(fa => [fa.player!.id, fa])
|
||||||
|
).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueFreeAgents.length !== freeAgents.length) {
|
||||||
|
logger.warn('Duplicate free agents detected', {
|
||||||
|
freeAgentCount: freeAgents.length,
|
||||||
|
uniquePlayerCount: uniqueFreeAgents.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueFreeAgents.length < 2) {
|
||||||
|
throw new Error("Need at least 2 unique players to create teams");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueFreeAgents.length % 2 !== 0) {
|
||||||
|
throw new Error("Need an even number of unique players to create teams");
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerIds = uniqueFreeAgents.map(fa => fa.player!.id);
|
||||||
|
|
||||||
|
const allTeams = await pbAdmin.getTeamsWithFilter(
|
||||||
|
playerIds.map(id => `players.id ?= "${id}"`).join(" || "),
|
||||||
|
"players,tournaments"
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidPairings = new Set<string>();
|
||||||
|
const mostRecentRegionalPartners = new Map<string, string>();
|
||||||
|
|
||||||
|
let mostRecentRegionalDate: Date | null = null;
|
||||||
|
|
||||||
|
for (const team of allTeams) {
|
||||||
|
const teamPlayers = (team.expand?.players || []) as any[];
|
||||||
|
if (teamPlayers.length !== 2) continue;
|
||||||
|
|
||||||
|
const [p1, p2] = teamPlayers.map((p: any) => p.id).sort();
|
||||||
|
const pairKey = `${p1}|${p2}`;
|
||||||
|
|
||||||
|
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
||||||
|
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
|
||||||
|
|
||||||
|
if (hasMainlineTournament) {
|
||||||
|
invalidPairings.add(pairKey);
|
||||||
|
} else if (team.private && teamTournaments.length > 0) {
|
||||||
|
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
|
||||||
|
for (const tournament of regionalTournaments) {
|
||||||
|
const tournamentDate = new Date(tournament.start_time);
|
||||||
|
if (!mostRecentRegionalDate || tournamentDate > mostRecentRegionalDate) {
|
||||||
|
mostRecentRegionalDate = tournamentDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mostRecentRegionalDate) {
|
||||||
|
for (const team of allTeams) {
|
||||||
|
if (!team.private) continue;
|
||||||
|
|
||||||
|
const teamPlayers = (team.expand?.players || []) as any[];
|
||||||
|
if (teamPlayers.length !== 2) continue;
|
||||||
|
|
||||||
|
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
||||||
|
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
|
||||||
|
|
||||||
|
for (const tournament of regionalTournaments) {
|
||||||
|
const tournamentDate = new Date(tournament.start_time);
|
||||||
|
if (tournamentDate.getTime() === mostRecentRegionalDate.getTime()) {
|
||||||
|
const [p1Id, p2Id] = teamPlayers.map((p: any) => p.id);
|
||||||
|
mostRecentRegionalPartners.set(p1Id, p2Id);
|
||||||
|
mostRecentRegionalPartners.set(p2Id, p1Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPairPlayers(p1Id: string, p2Id: string): boolean {
|
||||||
|
const pairKey = [p1Id, p2Id].sort().join('|');
|
||||||
|
if (invalidPairings.has(pairKey)) return false;
|
||||||
|
|
||||||
|
const p1LastPartner = mostRecentRegionalPartners.get(p1Id);
|
||||||
|
if (p1LastPartner === p2Id) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = data.seed || Math.floor(Math.random() * 1000000);
|
||||||
|
|
||||||
|
function seededRandom(s: number) {
|
||||||
|
const x = Math.sin(s++) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentSeed = seed;
|
||||||
|
const shuffled = [...uniqueFreeAgents];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = [];
|
||||||
|
const paired = new Set<string>();
|
||||||
|
const MAX_ATTEMPTS = 1000;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (paired.size < shuffled.length && attempts < MAX_ATTEMPTS) {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
for (let i = 0; i < shuffled.length; i++) {
|
||||||
|
if (paired.has(shuffled[i].player!.id)) continue;
|
||||||
|
|
||||||
|
for (let j = i + 1; j < shuffled.length; j++) {
|
||||||
|
if (paired.has(shuffled[j].player!.id)) continue;
|
||||||
|
|
||||||
|
const player1 = shuffled[i].player!;
|
||||||
|
const player2 = shuffled[j].player!;
|
||||||
|
|
||||||
|
if (canPairPlayers(player1.id, player2.id)) {
|
||||||
|
const teamName = `${player1.first_name} And ${player2.first_name}`;
|
||||||
|
|
||||||
|
assignments.push({
|
||||||
|
player1,
|
||||||
|
player2,
|
||||||
|
teamName
|
||||||
|
});
|
||||||
|
|
||||||
|
paired.add(player1.id);
|
||||||
|
paired.add(player2.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paired.size < shuffled.length) {
|
||||||
|
currentSeed++;
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
assignments.length = 0;
|
||||||
|
paired.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paired.size < shuffled.length) {
|
||||||
|
throw new Error("Unable to create valid pairings with current restrictions. Please manually adjust enrollments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Generated random team assignments with restrictions', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
teamCount: assignments.length,
|
||||||
|
seed,
|
||||||
|
attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
return { assignments, seed };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const confirmTeamAssignments = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
assignments: z.array(z.object({
|
||||||
|
player1Id: z.string(),
|
||||||
|
player2Id: z.string(),
|
||||||
|
teamName: z.string()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const createdTeams = [];
|
||||||
|
let reusedCount = 0;
|
||||||
|
|
||||||
|
for (const assignment of data.assignments) {
|
||||||
|
const existingTeams = await pbAdmin.getTeamsWithFilter(
|
||||||
|
`private = true && players.id ?= "${assignment.player1Id}" && players.id ?= "${assignment.player2Id}"`,
|
||||||
|
"players,tournaments"
|
||||||
|
);
|
||||||
|
|
||||||
|
let teamToUse = null;
|
||||||
|
|
||||||
|
for (const team of existingTeams) {
|
||||||
|
const teamPlayers = (team.expand?.players || []) as any[];
|
||||||
|
|
||||||
|
if (teamPlayers.length !== 2) continue;
|
||||||
|
|
||||||
|
const playerIds = teamPlayers.map((p: any) => p.id).sort();
|
||||||
|
const assignmentIds = [assignment.player1Id, assignment.player2Id].sort();
|
||||||
|
if (playerIds[0] !== assignmentIds[0] || playerIds[1] !== assignmentIds[1]) continue;
|
||||||
|
|
||||||
|
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
||||||
|
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
|
||||||
|
|
||||||
|
if (!hasMainlineTournament) {
|
||||||
|
teamToUse = team;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamToUse) {
|
||||||
|
await pbAdmin.enrollTeam(data.tournamentId, teamToUse.id);
|
||||||
|
createdTeams.push(teamToUse);
|
||||||
|
reusedCount++;
|
||||||
|
logger.info('Reusing existing regional team', { teamId: teamToUse.id, teamName: teamToUse.name });
|
||||||
|
} else {
|
||||||
|
const team = await pbAdmin.createTeam({
|
||||||
|
name: assignment.teamName,
|
||||||
|
players: [assignment.player1Id, assignment.player2Id],
|
||||||
|
private: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await pbAdmin.enrollTeam(data.tournamentId, team.id);
|
||||||
|
createdTeams.push(team);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignment of data.assignments) {
|
||||||
|
await pbAdmin.unenrollFreeAgent(assignment.player1Id, data.tournamentId);
|
||||||
|
await pbAdmin.unenrollFreeAgent(assignment.player2Id, data.tournamentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Confirmed team assignments', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
teamCount: createdTeams.length,
|
||||||
|
reusedCount,
|
||||||
|
newCount: createdTeams.length - reusedCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return { teams: createdTeams };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
async function calculateGroupStandings(groupId: string): Promise<GroupStanding[]> {
|
||||||
|
const group = await pbAdmin.getGroup(groupId);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error("Group not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await pbAdmin.getMatchesByGroup(groupId);
|
||||||
|
const completedMatches = matches.filter(m => m.status === "ended");
|
||||||
|
|
||||||
|
const standings = new Map<string, GroupStanding>();
|
||||||
|
|
||||||
|
for (const team of group.teams || []) {
|
||||||
|
standings.set(team.id, {
|
||||||
|
team,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
cups_for: 0,
|
||||||
|
cups_against: 0,
|
||||||
|
cup_differential: 0,
|
||||||
|
rank: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of completedMatches) {
|
||||||
|
if (!match.home || !match.away) continue;
|
||||||
|
|
||||||
|
const homeStanding = standings.get(match.home.id);
|
||||||
|
const awayStanding = standings.get(match.away.id);
|
||||||
|
|
||||||
|
if (homeStanding && awayStanding) {
|
||||||
|
homeStanding.cups_for += match.home_cups;
|
||||||
|
homeStanding.cups_against += match.away_cups;
|
||||||
|
awayStanding.cups_for += match.away_cups;
|
||||||
|
awayStanding.cups_against += match.home_cups;
|
||||||
|
|
||||||
|
if (match.home_cups > match.away_cups) {
|
||||||
|
homeStanding.wins++;
|
||||||
|
awayStanding.losses++;
|
||||||
|
} else {
|
||||||
|
awayStanding.wins++;
|
||||||
|
homeStanding.losses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const standing of standings.values()) {
|
||||||
|
standing.cup_differential = standing.cups_for - standing.cups_against;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h2hRecords = new Map<string, Map<string, { wins: number; cupDiff: number }>>();
|
||||||
|
|
||||||
|
for (const match of completedMatches) {
|
||||||
|
if (!match.home || !match.away) continue;
|
||||||
|
|
||||||
|
if (!h2hRecords.has(match.home.id)) {
|
||||||
|
h2hRecords.set(match.home.id, new Map());
|
||||||
|
}
|
||||||
|
if (!h2hRecords.has(match.away.id)) {
|
||||||
|
h2hRecords.set(match.away.id, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeH2H = h2hRecords.get(match.home.id)!;
|
||||||
|
const awayH2H = h2hRecords.get(match.away.id)!;
|
||||||
|
|
||||||
|
if (!homeH2H.has(match.away.id)) {
|
||||||
|
homeH2H.set(match.away.id, { wins: 0, cupDiff: 0 });
|
||||||
|
}
|
||||||
|
if (!awayH2H.has(match.home.id)) {
|
||||||
|
awayH2H.set(match.home.id, { wins: 0, cupDiff: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeRecord = homeH2H.get(match.away.id)!;
|
||||||
|
const awayRecord = awayH2H.get(match.home.id)!;
|
||||||
|
|
||||||
|
const cupDiff = match.home_cups - match.away_cups;
|
||||||
|
homeRecord.cupDiff += cupDiff;
|
||||||
|
awayRecord.cupDiff -= cupDiff;
|
||||||
|
|
||||||
|
if (match.home_cups > match.away_cups) {
|
||||||
|
homeRecord.wins++;
|
||||||
|
} else {
|
||||||
|
awayRecord.wins++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedStandings = Array.from(standings.values()).sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
|
||||||
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
||||||
|
|
||||||
|
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
|
||||||
|
|
||||||
|
const aH2H = h2hRecords.get(a.team.id);
|
||||||
|
const bH2H = h2hRecords.get(b.team.id);
|
||||||
|
|
||||||
|
if (aH2H && bH2H) {
|
||||||
|
const aVsB = aH2H.get(b.team.id);
|
||||||
|
const bVsA = bH2H.get(a.team.id);
|
||||||
|
|
||||||
|
if (aVsB && bVsA) {
|
||||||
|
if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins;
|
||||||
|
|
||||||
|
if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.team.id.localeCompare(b.team.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedStandings.forEach((standing, index) => {
|
||||||
|
standing.rank = index + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedStandings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGroupStandings = createServerFn()
|
||||||
|
.inputValidator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: groupId }) =>
|
||||||
|
toServerResult(() => calculateGroupStandings(groupId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateKnockoutBracket = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
logger.info('Generating knockout bracket', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(data.tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.phase !== "group_stage") {
|
||||||
|
throw new Error("Tournament must be in group_stage phase to generate knockout bracket");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tournament.group_config) {
|
||||||
|
throw new Error("Tournament must have group_config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await pbAdmin.getGroupsByTournament(data.tournamentId);
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
throw new Error("No groups found for tournament");
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const standings = await calculateGroupStandings(group.id);
|
||||||
|
|
||||||
|
const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
|
||||||
|
for (const standing of topTeams) {
|
||||||
|
qualifiedTeams.push({
|
||||||
|
teamId: standing.team.id,
|
||||||
|
groupOrder: group.order,
|
||||||
|
rank: standing.rank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedTeamIds: string[] = [];
|
||||||
|
const maxRank = tournament.group_config.advance_per_group;
|
||||||
|
const numGroups = tournament.group_config.num_groups;
|
||||||
|
|
||||||
|
const teamsByGroup: string[][] = [];
|
||||||
|
for (let g = 0; g < numGroups; g++) {
|
||||||
|
teamsByGroup[g] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const qualified of qualifiedTeams) {
|
||||||
|
teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTeams = numGroups * maxRank;
|
||||||
|
for (let i = 0; i < totalTeams / 2; i++) {
|
||||||
|
const group1 = i % numGroups;
|
||||||
|
const rankIndex1 = Math.floor(i / numGroups);
|
||||||
|
|
||||||
|
const group2 = (i + 1) % numGroups;
|
||||||
|
const rankIndex2 = maxRank - 1 - rankIndex1;
|
||||||
|
|
||||||
|
const team1 = teamsByGroup[group1]?.[rankIndex1];
|
||||||
|
const team2 = teamsByGroup[group2]?.[rankIndex2];
|
||||||
|
|
||||||
|
if (team1) orderedTeamIds.push(team1);
|
||||||
|
if (team2) orderedTeamIds.push(team2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamCount = orderedTeamIds.length;
|
||||||
|
|
||||||
|
let bracketTemplate: any;
|
||||||
|
if (Object.keys(brackets).includes(teamCount.toString())) {
|
||||||
|
bracketTemplate = brackets[teamCount as keyof typeof brackets];
|
||||||
|
} else {
|
||||||
|
bracketTemplate = generateSingleEliminationBracket(teamCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedToTeamId = new Map<number, string>();
|
||||||
|
orderedTeamIds.forEach((teamId, index) => {
|
||||||
|
seedToTeamId.set(index + 1, teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchInputs: MatchInput[] = [];
|
||||||
|
let matchLid = 1000;
|
||||||
|
|
||||||
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
|
round.forEach((match: any) => {
|
||||||
|
const matchInput: MatchInput = {
|
||||||
|
lid: matchLid++,
|
||||||
|
round: match.round,
|
||||||
|
order: match.order || 0,
|
||||||
|
reset: false,
|
||||||
|
bye: match.bye || false,
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
home_from_lid: match.home_from_lid === null ? -1 : (match.home_from_lid + 1000),
|
||||||
|
away_from_lid: match.away_from_lid === null ? -1 : (match.away_from_lid + 1000),
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_loser: false,
|
||||||
|
is_losers_bracket: false,
|
||||||
|
match_type: "knockout",
|
||||||
|
status: "tbd",
|
||||||
|
tournament: data.tournamentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (match.home_seed) {
|
||||||
|
const teamId = seedToTeamId.get(match.home_seed);
|
||||||
|
if (teamId) {
|
||||||
|
matchInput.home = teamId;
|
||||||
|
matchInput.home_seed = match.home_seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.away_seed) {
|
||||||
|
const teamId = seedToTeamId.get(match.away_seed);
|
||||||
|
if (teamId) {
|
||||||
|
matchInput.away = teamId;
|
||||||
|
matchInput.away_seed = match.away_seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchInput.home && matchInput.away) {
|
||||||
|
matchInput.status = "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
matchInputs.push(matchInput);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
||||||
|
|
||||||
|
const existingMatchIds = tournament.matches?.map(m => m.id) || [];
|
||||||
|
const newMatchIds = createdMatches.map(m => m.id);
|
||||||
|
await pbAdmin.updateTournamentMatches(data.tournamentId, [...existingMatchIds, ...newMatchIds]);
|
||||||
|
|
||||||
|
await pbAdmin.updateTournament(data.tournamentId, {
|
||||||
|
phase: "knockout"
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Knockout bracket generated', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
matchCount: createdMatches.length,
|
||||||
|
qualifiedTeamCount: qualifiedTeams.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
matchCount: createdMatches.length,
|
||||||
|
matches: createdMatches
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const adminEnrollPlayer = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
playerId: z.string(),
|
||||||
|
tournamentId: z.string()
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
||||||
|
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const adminUnenrollPlayer = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
playerId: z.string(),
|
||||||
|
tournamentId: z.string()
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
||||||
|
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateGroupStage = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
groupConfig: z.object({
|
||||||
|
num_groups: z.number(),
|
||||||
|
teams_per_group: z.number(),
|
||||||
|
advance_per_group: z.number(),
|
||||||
|
matches_guaranteed: z.number(),
|
||||||
|
seeding_method: z.enum(["random", "ranked"]),
|
||||||
|
}),
|
||||||
|
teamAssignments: z.array(z.object({
|
||||||
|
groupIndex: z.number(),
|
||||||
|
groupName: z.string(),
|
||||||
|
teamIds: z.array(z.string())
|
||||||
|
})),
|
||||||
|
seed: z.number().optional()
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
logger.info('Generating group stage', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
numGroups: data.groupConfig.num_groups,
|
||||||
|
seed: data.seed
|
||||||
|
});
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(data.tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.matches && tournament.matches.length > 0) {
|
||||||
|
throw new Error("Tournament already has matches generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
await pbAdmin.deleteGroupsByTournament(data.tournamentId);
|
||||||
|
|
||||||
|
const createdGroups = [];
|
||||||
|
const groupStageMatches = [];
|
||||||
|
|
||||||
|
for (const assignment of data.teamAssignments) {
|
||||||
|
const group = await pbAdmin.createGroup({
|
||||||
|
tournament: data.tournamentId,
|
||||||
|
name: assignment.groupName,
|
||||||
|
order: assignment.groupIndex,
|
||||||
|
teams: assignment.teamIds
|
||||||
|
});
|
||||||
|
|
||||||
|
createdGroups.push(group);
|
||||||
|
|
||||||
|
const teamIds = assignment.teamIds;
|
||||||
|
|
||||||
|
for (let i = 0; i < teamIds.length; i++) {
|
||||||
|
for (let j = i + 1; j < teamIds.length; j++) {
|
||||||
|
groupStageMatches.push({
|
||||||
|
lid: -1,
|
||||||
|
round: -1,
|
||||||
|
order: groupStageMatches.length + 1,
|
||||||
|
reset: false,
|
||||||
|
bye: false,
|
||||||
|
home: teamIds[i],
|
||||||
|
away: teamIds[j],
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
home_from_lid: -1,
|
||||||
|
away_from_lid: -1,
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_loser: false,
|
||||||
|
is_losers_bracket: false,
|
||||||
|
match_type: "group_stage" as const,
|
||||||
|
group: group.id,
|
||||||
|
status: "ready" as const,
|
||||||
|
tournament: data.tournamentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
||||||
|
let bracketTemplate: any;
|
||||||
|
|
||||||
|
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
||||||
|
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
||||||
|
} else {
|
||||||
|
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const knockoutMatches: any[] = [];
|
||||||
|
|
||||||
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
|
round.forEach((match: any) => {
|
||||||
|
knockoutMatches.push({
|
||||||
|
lid: match.lid,
|
||||||
|
round: match.round,
|
||||||
|
order: match.order,
|
||||||
|
reset: false,
|
||||||
|
bye: match.bye || false,
|
||||||
|
home_seed: match.home_seed,
|
||||||
|
away_seed: match.away_seed,
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
home_from_lid: match.home_from_lid !== null ? match.home_from_lid : -1,
|
||||||
|
away_from_lid: match.away_from_lid !== null ? match.away_from_lid : -1,
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_loser: false,
|
||||||
|
is_losers_bracket: false,
|
||||||
|
match_type: "knockout" as const,
|
||||||
|
status: "tbd" as const,
|
||||||
|
tournament: data.tournamentId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMatches = [...groupStageMatches, ...knockoutMatches];
|
||||||
|
const createdMatches = await pbAdmin.createMatches(allMatches);
|
||||||
|
|
||||||
|
const matchIds = createdMatches.map((match) => match.id);
|
||||||
|
await pbAdmin.updateTournamentMatches(data.tournamentId, matchIds);
|
||||||
|
|
||||||
|
await pbAdmin.updateTournament(data.tournamentId, {
|
||||||
|
phase: "group_stage",
|
||||||
|
group_config: data.groupConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Group stage and knockout bracket generated', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
groupCount: createdGroups.length,
|
||||||
|
groupMatchCount: groupStageMatches.length,
|
||||||
|
knockoutMatchCount: knockoutMatches.length,
|
||||||
|
totalMatchCount: createdMatches.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
groups: createdGroups,
|
||||||
|
matchCount: createdMatches.length,
|
||||||
|
matches: createdMatches
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,37 @@ import { TeamInfo } from "@/features/teams/types";
|
|||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type TournamentFormat = "single_elim" | "double_elim" | "groups" | "swiss" | "swiss_bracket" | "round_robin";
|
||||||
|
export type TournamentPhase = "seeding" | "group_stage" | "knockout" | "completed";
|
||||||
|
|
||||||
|
export interface GroupConfig {
|
||||||
|
num_groups: number;
|
||||||
|
teams_per_group: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
matches_guaranteed: number;
|
||||||
|
seeding_method: "random" | "ranked";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
tournament: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
teams: TeamInfo[];
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupStanding {
|
||||||
|
team: TeamInfo;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
cups_for: number;
|
||||||
|
cups_against: number;
|
||||||
|
cup_differential: number;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TournamentTeamStats {
|
export interface TournamentTeamStats {
|
||||||
id: string;
|
id: string;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
@@ -52,6 +83,10 @@ export interface Tournament {
|
|||||||
third_place?: TeamInfo;
|
third_place?: TeamInfo;
|
||||||
team_stats?: TournamentTeamStats[];
|
team_stats?: TournamentTeamStats[];
|
||||||
regional?: boolean;
|
regional?: boolean;
|
||||||
|
format?: TournamentFormat;
|
||||||
|
group_config?: GroupConfig;
|
||||||
|
phase?: TournamentPhase;
|
||||||
|
groups?: Group[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tournamentInputSchema = z.object({
|
export const tournamentInputSchema = z.object({
|
||||||
@@ -64,6 +99,15 @@ export const tournamentInputSchema = z.object({
|
|||||||
start_time: z.string(),
|
start_time: z.string(),
|
||||||
end_time: z.string().optional(),
|
end_time: z.string().optional(),
|
||||||
regional: z.boolean().optional().default(false),
|
regional: z.boolean().optional().default(false),
|
||||||
|
format: z.enum(["single_elim", "double_elim", "groups", "swiss", "swiss_bracket", "round_robin"]).optional(),
|
||||||
|
phase: z.enum(["seeding", "group_stage", "knockout", "completed"]).optional(),
|
||||||
|
group_config: z.object({
|
||||||
|
num_groups: z.number(),
|
||||||
|
teams_per_group: z.number(),
|
||||||
|
advance_per_group: z.number(),
|
||||||
|
matches_guaranteed: z.number(),
|
||||||
|
seeding_method: z.enum(["random", "ranked"]),
|
||||||
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
|
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
|
||||||
|
|||||||
103
src/features/tournaments/utils/bracket-generator.ts
Normal file
103
src/features/tournaments/utils/bracket-generator.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export interface BracketMatch {
|
||||||
|
lid: number;
|
||||||
|
round: number;
|
||||||
|
order: number | null;
|
||||||
|
bye: boolean;
|
||||||
|
home_seed?: number;
|
||||||
|
away_seed?: number;
|
||||||
|
home_from_lid: number | null;
|
||||||
|
home_from_loser: boolean;
|
||||||
|
away_from_lid: number | null;
|
||||||
|
away_from_loser: boolean;
|
||||||
|
reset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BracketTemplate {
|
||||||
|
winners: BracketMatch[][];
|
||||||
|
losers: BracketMatch[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSingleEliminationBracket(teamCount: number): BracketTemplate {
|
||||||
|
if (teamCount < 2) {
|
||||||
|
throw new Error("Need at least 2 teams for a bracket");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(teamCount)));
|
||||||
|
const totalRounds = Math.log2(nextPowerOf2);
|
||||||
|
|
||||||
|
const byesNeeded = nextPowerOf2 - teamCount;
|
||||||
|
const firstRoundMatches = Math.floor(teamCount / 2);
|
||||||
|
|
||||||
|
const winners: BracketMatch[][] = [];
|
||||||
|
let currentLid = 0;
|
||||||
|
let currentOrder = 1;
|
||||||
|
|
||||||
|
for (let round = 0; round < totalRounds; round++) {
|
||||||
|
const roundMatches: BracketMatch[] = [];
|
||||||
|
const matchesInRound = Math.pow(2, totalRounds - round - 1);
|
||||||
|
|
||||||
|
for (let matchIndex = 0; matchIndex < matchesInRound; matchIndex++) {
|
||||||
|
const match: BracketMatch = {
|
||||||
|
lid: currentLid++,
|
||||||
|
round,
|
||||||
|
order: currentOrder++,
|
||||||
|
bye: false,
|
||||||
|
home_from_lid: null,
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_lid: null,
|
||||||
|
away_from_loser: false,
|
||||||
|
reset: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (round === 0) {
|
||||||
|
const homePosition = matchIndex * 2;
|
||||||
|
const awayPosition = matchIndex * 2 + 1;
|
||||||
|
|
||||||
|
if (homePosition < teamCount && awayPosition < teamCount) {
|
||||||
|
match.home_seed = homePosition + 1;
|
||||||
|
match.away_seed = awayPosition + 1;
|
||||||
|
} else if (homePosition < teamCount) {
|
||||||
|
match.home_seed = homePosition + 1;
|
||||||
|
match.bye = true;
|
||||||
|
} else {
|
||||||
|
match.bye = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const prevRound = winners[round - 1];
|
||||||
|
const homeFeedIndex = matchIndex * 2;
|
||||||
|
const awayFeedIndex = matchIndex * 2 + 1;
|
||||||
|
|
||||||
|
if (homeFeedIndex < prevRound.length) {
|
||||||
|
match.home_from_lid = prevRound[homeFeedIndex].lid;
|
||||||
|
}
|
||||||
|
if (awayFeedIndex < prevRound.length) {
|
||||||
|
match.away_from_lid = prevRound[awayFeedIndex].lid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roundMatches.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
winners.push(roundMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
winners,
|
||||||
|
losers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateGroupMismatchSeeding(
|
||||||
|
numGroups: number,
|
||||||
|
teamsPerGroup: number
|
||||||
|
): number[] {
|
||||||
|
const seeding: number[] = [];
|
||||||
|
|
||||||
|
for (let rank = 0; rank < teamsPerGroup; rank++) {
|
||||||
|
for (let group = 0; group < numGroups; group++) {
|
||||||
|
seeding.push(group * teamsPerGroup + rank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeding;
|
||||||
|
}
|
||||||
187
src/features/tournaments/utils/group-config.ts
Normal file
187
src/features/tournaments/utils/group-config.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { GroupConfig } from "../types";
|
||||||
|
|
||||||
|
function isPowerOfTwo(n: number): boolean {
|
||||||
|
return n > 0 && (n & (n - 1)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextPowerOfTwo(n: number): number {
|
||||||
|
if (n <= 0) return 1;
|
||||||
|
if (isPowerOfTwo(n)) return n;
|
||||||
|
return Math.pow(2, Math.ceil(Math.log2(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupConfigOption extends GroupConfig {
|
||||||
|
groups_with_extra: number;
|
||||||
|
knockout_size: number;
|
||||||
|
wildcards_needed: number;
|
||||||
|
total_group_matches: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateGroupConfigurations(teamCount: number): GroupConfigOption[] {
|
||||||
|
if (teamCount < 4) {
|
||||||
|
throw new Error("Need at least 4 teams for group stage");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs: GroupConfigOption[] = [];
|
||||||
|
|
||||||
|
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
|
||||||
|
const numGroups = Math.floor(teamCount / teamsPerGroup);
|
||||||
|
const remainder = teamCount % teamsPerGroup;
|
||||||
|
|
||||||
|
if (numGroups < 2) continue;
|
||||||
|
|
||||||
|
if (remainder > numGroups) continue;
|
||||||
|
|
||||||
|
const groupsWithExtra = remainder;
|
||||||
|
|
||||||
|
const groupsAtBaseSize = numGroups - groupsWithExtra;
|
||||||
|
const minGroupSize = groupsAtBaseSize > 0 ? teamsPerGroup : teamsPerGroup + 1;
|
||||||
|
const matchesGuaranteed = minGroupSize - 1;
|
||||||
|
|
||||||
|
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) {
|
||||||
|
const teamsAdvancing = numGroups * advancePerGroup;
|
||||||
|
|
||||||
|
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
|
||||||
|
|
||||||
|
const knockoutSize = getNextPowerOfTwo(teamsAdvancing);
|
||||||
|
const wildcardsNeeded = knockoutSize - teamsAdvancing;
|
||||||
|
|
||||||
|
if (wildcardsNeeded > teamsAdvancing / 2) continue;
|
||||||
|
|
||||||
|
let totalGroupMatches = 0;
|
||||||
|
for (let i = 0; i < numGroups; i++) {
|
||||||
|
const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
|
||||||
|
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = generateDescription({
|
||||||
|
num_groups: numGroups,
|
||||||
|
teams_per_group: teamsPerGroup,
|
||||||
|
groups_with_extra: groupsWithExtra,
|
||||||
|
advance_per_group: advancePerGroup,
|
||||||
|
matches_guaranteed: matchesGuaranteed,
|
||||||
|
knockout_size: knockoutSize,
|
||||||
|
wildcards_needed: wildcardsNeeded,
|
||||||
|
});
|
||||||
|
|
||||||
|
configs.push({
|
||||||
|
num_groups: numGroups,
|
||||||
|
teams_per_group: teamsPerGroup,
|
||||||
|
advance_per_group: advancePerGroup,
|
||||||
|
matches_guaranteed: matchesGuaranteed,
|
||||||
|
seeding_method: "random",
|
||||||
|
groups_with_extra: groupsWithExtra,
|
||||||
|
knockout_size: knockoutSize,
|
||||||
|
wildcards_needed: wildcardsNeeded,
|
||||||
|
total_group_matches: totalGroupMatches,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueConfigs = new Map<string, GroupConfigOption>();
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const groupSizes: number[] = [];
|
||||||
|
for (let i = 0; i < config.num_groups; i++) {
|
||||||
|
const size = config.teams_per_group + (i < config.groups_with_extra ? 1 : 0);
|
||||||
|
groupSizes.push(size);
|
||||||
|
}
|
||||||
|
groupSizes.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
const key = `${groupSizes.join(',')}_advance${config.advance_per_group}`;
|
||||||
|
|
||||||
|
if (!uniqueConfigs.has(key)) {
|
||||||
|
uniqueConfigs.set(key, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(uniqueConfigs.values()).sort((a, b) => {
|
||||||
|
if (a.matches_guaranteed !== b.matches_guaranteed) {
|
||||||
|
return b.matches_guaranteed - a.matches_guaranteed;
|
||||||
|
}
|
||||||
|
if (a.wildcards_needed !== b.wildcards_needed) {
|
||||||
|
return a.wildcards_needed - b.wildcards_needed;
|
||||||
|
}
|
||||||
|
if (a.knockout_size !== b.knockout_size) {
|
||||||
|
return b.knockout_size - a.knockout_size;
|
||||||
|
}
|
||||||
|
return b.num_groups - a.num_groups;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDescription(config: Partial<GroupConfigOption>): string {
|
||||||
|
const { num_groups, teams_per_group, groups_with_extra, matches_guaranteed, advance_per_group, knockout_size, wildcards_needed } = config;
|
||||||
|
|
||||||
|
let desc = '';
|
||||||
|
|
||||||
|
if (groups_with_extra && groups_with_extra > 0) {
|
||||||
|
const largerGroupSize = teams_per_group + 1;
|
||||||
|
const smallerGroupCount = num_groups! - groups_with_extra;
|
||||||
|
|
||||||
|
if (smallerGroupCount > 0) {
|
||||||
|
desc += `${groups_with_extra} group${groups_with_extra > 1 ? 's' : ''} of ${largerGroupSize}, `;
|
||||||
|
desc += `${smallerGroupCount} group${smallerGroupCount > 1 ? 's' : ''} of ${teams_per_group}`;
|
||||||
|
} else {
|
||||||
|
desc += `${num_groups} group${num_groups! > 1 ? 's' : ''} of ${largerGroupSize}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
desc += `${num_groups} group${num_groups! > 1 ? 's' : ''} of ${teams_per_group}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
desc += ` • ${matches_guaranteed} match${matches_guaranteed! > 1 ? 'es' : ''} guaranteed`;
|
||||||
|
desc += ` • Top ${advance_per_group} advance`;
|
||||||
|
|
||||||
|
if (wildcards_needed && wildcards_needed > 0) {
|
||||||
|
desc += ` + ${wildcards_needed} wildcard${wildcards_needed > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
desc += ` → ${knockout_size}-team knockout`;
|
||||||
|
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignTeamsToGroups(
|
||||||
|
teamIds: string[],
|
||||||
|
config: GroupConfigOption,
|
||||||
|
seed?: number
|
||||||
|
): string[][] {
|
||||||
|
const shuffled = shuffleArray([...teamIds], seed);
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
let teamIndex = 0;
|
||||||
|
|
||||||
|
for (let groupIndex = 0; groupIndex < config.num_groups; groupIndex++) {
|
||||||
|
const groupSize = config.teams_per_group + (groupIndex < config.groups_with_extra ? 1 : 0);
|
||||||
|
const groupTeams = shuffled.slice(teamIndex, teamIndex + groupSize);
|
||||||
|
groups.push(groupTeams);
|
||||||
|
teamIndex += groupSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleArray<T>(array: T[], seed?: number): T[] {
|
||||||
|
const arr = [...array];
|
||||||
|
const random = seed !== undefined ? seededRandom(seed) : Math.random;
|
||||||
|
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seededRandom(seed: number): () => number {
|
||||||
|
let value = seed;
|
||||||
|
return () => {
|
||||||
|
value = (value * 9301 + 49297) % 233280;
|
||||||
|
return value / 233280;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupName(index: number): string {
|
||||||
|
return String.fromCharCode(65 + index);
|
||||||
|
}
|
||||||
15
src/hooks/use-is-pwa.ts
Normal file
15
src/hooks/use-is-pwa.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useIsPWA(): boolean {
|
||||||
|
const [isPWA, setIsPWA] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
|
||||||
|
const isIOSStandalone = 'standalone' in window.navigator && (window.navigator as any).standalone
|
||||||
|
|
||||||
|
setIsPWA(isStandalone || isIOSStandalone)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isPWA
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ class Logger {
|
|||||||
constructor(context?: string, options: LoggerOptions = {}) {
|
constructor(context?: string, options: LoggerOptions = {}) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.options = {
|
this.options = {
|
||||||
enabled: import.meta.env.NODE_ENV !== "production",
|
enabled: true,
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
@@ -75,27 +75,44 @@ class Logger {
|
|||||||
|
|
||||||
const groupLabel = `${timestamp}${style.label}${context} │ ${label}`;
|
const groupLabel = `${timestamp}${style.label}${context} │ ${label}`;
|
||||||
|
|
||||||
const group = this.options.collapsed
|
// In server environment (no window), use simple console.log instead of groups
|
||||||
? console.groupCollapsed
|
const isServer = typeof window === "undefined";
|
||||||
: console.group;
|
|
||||||
|
|
||||||
if (this.options.colors && typeof window !== "undefined") {
|
if (isServer) {
|
||||||
group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
|
// Server-side: Simple formatted output (no console.group in Node.js)
|
||||||
} else {
|
console.log(groupLabel);
|
||||||
group(groupLabel);
|
if (data !== undefined) {
|
||||||
}
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
if (data !== undefined) {
|
|
||||||
console.log(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rest.length > 0) {
|
|
||||||
for (const item of rest) {
|
|
||||||
console.log(item);
|
|
||||||
}
|
}
|
||||||
}
|
if (rest.length > 0) {
|
||||||
|
for (const item of rest) {
|
||||||
|
console.log(JSON.stringify(item, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser: Use console.group with colors
|
||||||
|
const group = this.options.collapsed
|
||||||
|
? console.groupCollapsed
|
||||||
|
: console.group;
|
||||||
|
|
||||||
console.groupEnd();
|
if (this.options.colors) {
|
||||||
|
group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
|
||||||
|
} else {
|
||||||
|
group(groupLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data !== undefined) {
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length > 0) {
|
||||||
|
for (const item of rest) {
|
||||||
|
console.log(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info(label: string, data?: any, ...rest: any[]): void {
|
info(label: string, data?: any, ...rest: any[]): void {
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { createMatchesService } from "./services/matches";
|
|||||||
import { createReactionsService } from "./services/reactions";
|
import { createReactionsService } from "./services/reactions";
|
||||||
import { createActivitiesService } from "./services/activities";
|
import { createActivitiesService } from "./services/activities";
|
||||||
import { createBadgesService } from "./services/badges";
|
import { createBadgesService } from "./services/badges";
|
||||||
import dotenv from 'dotenv';
|
import { createGroupsService } from "./services/groups";
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
class PocketBaseAdminClient {
|
class PocketBaseAdminClient {
|
||||||
private pb: PocketBase;
|
private pb: PocketBase;
|
||||||
@@ -48,6 +47,7 @@ class PocketBaseAdminClient {
|
|||||||
Object.assign(this, createReactionsService(this.pb));
|
Object.assign(this, createReactionsService(this.pb));
|
||||||
Object.assign(this, createActivitiesService(this.pb));
|
Object.assign(this, createActivitiesService(this.pb));
|
||||||
Object.assign(this, createBadgesService(this.pb));
|
Object.assign(this, createBadgesService(this.pb));
|
||||||
|
Object.assign(this, createGroupsService(this.pb));
|
||||||
|
|
||||||
this.authPromise = this.authenticate();
|
this.authPromise = this.authenticate();
|
||||||
this.authPromise.then(() => {
|
this.authPromise.then(() => {
|
||||||
@@ -125,7 +125,8 @@ interface AdminClient
|
|||||||
ReturnType<typeof createMatchesService>,
|
ReturnType<typeof createMatchesService>,
|
||||||
ReturnType<typeof createReactionsService>,
|
ReturnType<typeof createReactionsService>,
|
||||||
ReturnType<typeof createActivitiesService>,
|
ReturnType<typeof createActivitiesService>,
|
||||||
ReturnType<typeof createBadgesService> {
|
ReturnType<typeof createBadgesService>,
|
||||||
|
ReturnType<typeof createGroupsService> {
|
||||||
authPromise: Promise<void>;
|
authPromise: Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,97 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
return bigWins.length > 0 ? 1 : 0;
|
return bigWins.length > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (criteria.unique_partners !== undefined) {
|
||||||
|
const matches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
const partners = new Set<string>();
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.home?.players?.includes(playerId);
|
||||||
|
const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.away?.players?.includes(playerId);
|
||||||
|
|
||||||
|
let teamPlayers: any[] = [];
|
||||||
|
if (isHome) {
|
||||||
|
teamPlayers = match.expand?.home?.expand?.players || match.expand?.home?.players || [];
|
||||||
|
} else if (isAway) {
|
||||||
|
teamPlayers = match.expand?.away?.expand?.players || match.expand?.away?.players || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of teamPlayers) {
|
||||||
|
const partnerId = typeof player === 'string' ? player : player.id;
|
||||||
|
if (partnerId !== playerId) {
|
||||||
|
partners.add(partnerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return partners.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.beat_mainline_partner !== undefined) {
|
||||||
|
const mainlineMatches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && (tournament.regional = false || tournament.regional = null)`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainlinePartners = new Set<string>();
|
||||||
|
for (const match of mainlineMatches) {
|
||||||
|
const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.home?.players?.includes(playerId);
|
||||||
|
const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.away?.players?.includes(playerId);
|
||||||
|
|
||||||
|
let teamPlayers: any[] = [];
|
||||||
|
if (isHome) {
|
||||||
|
teamPlayers = match.expand?.home?.expand?.players || match.expand?.home?.players || [];
|
||||||
|
} else if (isAway) {
|
||||||
|
teamPlayers = match.expand?.away?.expand?.players || match.expand?.away?.players || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of teamPlayers) {
|
||||||
|
const partnerId = typeof player === 'string' ? player : player.id;
|
||||||
|
if (partnerId !== playerId) {
|
||||||
|
mainlinePartners.add(partnerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionalMatches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && tournament.regional = true`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const match of regionalMatches) {
|
||||||
|
const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.home?.players?.includes(playerId);
|
||||||
|
const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.away?.players?.includes(playerId);
|
||||||
|
|
||||||
|
const didWin = (isHome && match.home_cups > match.away_cups) ||
|
||||||
|
(isAway && match.away_cups > match.home_cups);
|
||||||
|
|
||||||
|
if (!didWin) continue;
|
||||||
|
|
||||||
|
const opponentPlayers: any[] = isHome
|
||||||
|
? (match.expand?.away?.expand?.players || match.expand?.away?.players || [])
|
||||||
|
: (match.expand?.home?.expand?.players || match.expand?.home?.players || []);
|
||||||
|
|
||||||
|
for (const opponent of opponentPlayers) {
|
||||||
|
const opponentId = typeof opponent === 'string' ? opponent : opponent.id;
|
||||||
|
if (mainlinePartners.has(opponentId)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -314,18 +405,34 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.tournament_record !== undefined) {
|
if (criteria.tournament_record !== undefined) {
|
||||||
|
const includeRegional = criteria.tournament_record === "0-3";
|
||||||
|
const tournamentFilter = includeRegional
|
||||||
|
? ''
|
||||||
|
: 'regional = false || regional = null';
|
||||||
|
|
||||||
const tournaments = await pb.collection("tournaments").getFullList({
|
const tournaments = await pb.collection("tournaments").getFullList({
|
||||||
filter: 'regional = false || regional = null',
|
filter: tournamentFilter,
|
||||||
sort: 'start_time',
|
sort: 'start_time',
|
||||||
});
|
});
|
||||||
|
|
||||||
let timesWent02 = 0;
|
const allMatches = includeRegional
|
||||||
|
? await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
|
||||||
|
expand: 'tournament,home,away,home.players,away.players',
|
||||||
|
})
|
||||||
|
: matches;
|
||||||
|
|
||||||
for (const tournamentId of tournamentIds) {
|
const relevantTournamentIds = includeRegional
|
||||||
|
? new Set(allMatches.map(m => m.tournament))
|
||||||
|
: tournamentIds;
|
||||||
|
|
||||||
|
let timesMetRecord = 0;
|
||||||
|
|
||||||
|
for (const tournamentId of relevantTournamentIds) {
|
||||||
const tournament = tournaments.find(t => t.id === tournamentId);
|
const tournament = tournaments.find(t => t.id === tournamentId);
|
||||||
if (!tournament) continue;
|
if (!tournament) continue;
|
||||||
|
|
||||||
const tournamentMatches = matches.filter(m => m.tournament === tournamentId);
|
const tournamentMatches = allMatches.filter(m => m.tournament === tournamentId);
|
||||||
|
|
||||||
let wins = 0;
|
let wins = 0;
|
||||||
let losses = 0;
|
let losses = 0;
|
||||||
@@ -353,61 +460,144 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
const previousTournament = tournaments[currentIndex - 1];
|
const previousTournament = tournaments[currentIndex - 1];
|
||||||
if (previousTournament.winner_id === playerId) {
|
if (previousTournament.winner_id === playerId) {
|
||||||
timesWent02++;
|
timesMetRecord++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
timesWent02++;
|
timesMetRecord++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return timesWent02 > 0 ? 1 : 0;
|
return timesMetRecord > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.consecutive_wins !== undefined) {
|
if (criteria.consecutive_wins !== undefined) {
|
||||||
const tournaments = await pb.collection("tournaments").getFullList({
|
const allTournaments = await pb.collection("tournaments").getFullList({
|
||||||
filter: 'regional = false || regional = null',
|
filter: 'regional = false || regional = null',
|
||||||
sort: 'start_time',
|
sort: 'start_time',
|
||||||
});
|
});
|
||||||
|
|
||||||
let consecutiveWins = 0;
|
const tournamentResults: { tournament: any; playerWon: boolean }[] = [];
|
||||||
let maxConsecutiveWins = 0;
|
|
||||||
|
|
||||||
for (const tournament of tournaments) {
|
for (const tournament of allTournaments) {
|
||||||
if (!tournamentIds.has(tournament.id)) continue;
|
const matches = await pb.collection("matches").getFullList({
|
||||||
|
|
||||||
const tournamentMatches = await pb.collection("matches").getFullList({
|
|
||||||
filter: `tournament = "${tournament.id}" && status = "ended"`,
|
filter: `tournament = "${tournament.id}" && status = "ended"`,
|
||||||
expand: 'home,away,home.players,away.players',
|
expand: 'home,away,home.players,away.players',
|
||||||
});
|
});
|
||||||
|
|
||||||
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
|
if (matches.length === 0) {
|
||||||
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
continue;
|
||||||
(!highest || current.lid > highest.lid) ? current : highest, null);
|
}
|
||||||
|
|
||||||
if (finalsMatch && finalsMatch.status === 'ended') {
|
const winnersMatches = matches.filter(m => !m.is_losers_bracket);
|
||||||
const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
|
|
||||||
|
|
||||||
const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away'];
|
if (winnersMatches.length === 0) {
|
||||||
const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || [];
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const playerWon = winningPlayers.some((p: any) =>
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) => {
|
||||||
(typeof p === 'string' ? p : p.id) === playerId
|
if (!highest) return current;
|
||||||
);
|
return (current.lid > highest.lid) ? current : highest;
|
||||||
|
}, null);
|
||||||
|
|
||||||
if (playerWon) {
|
if (!finalsMatch || finalsMatch.status !== 'ended') {
|
||||||
consecutiveWins++;
|
continue;
|
||||||
maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins);
|
}
|
||||||
} else {
|
|
||||||
consecutiveWins = 0;
|
const winningTeamId = (finalsMatch.home_cups > finalsMatch.away_cups)
|
||||||
}
|
? finalsMatch.home
|
||||||
|
: finalsMatch.away;
|
||||||
|
|
||||||
|
const winningTeam = finalsMatch.expand?.[winningTeamId === finalsMatch.home ? 'home' : 'away'];
|
||||||
|
if (!winningTeam) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winningPlayers = winningTeam.expand?.players || winningTeam.players || [];
|
||||||
|
|
||||||
|
const playerWon = winningPlayers.some((p: any) => {
|
||||||
|
const pid = (typeof p === 'string') ? p : p.id;
|
||||||
|
return pid === playerId;
|
||||||
|
});
|
||||||
|
|
||||||
|
tournamentResults.push({
|
||||||
|
tournament,
|
||||||
|
playerWon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentStreak = 0;
|
||||||
|
let maxStreak = 0;
|
||||||
|
|
||||||
|
for (const result of tournamentResults) {
|
||||||
|
if (result.playerWon) {
|
||||||
|
currentStreak++;
|
||||||
|
maxStreak = Math.max(maxStreak, currentStreak);
|
||||||
} else {
|
} else {
|
||||||
consecutiveWins = 0;
|
currentStreak = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxConsecutiveWins >= criteria.consecutive_wins ? 1 : 0;
|
return maxStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.undefeated_group_stage !== undefined) {
|
||||||
|
const regionalTournaments = await pb.collection("tournaments").getFullList({
|
||||||
|
filter: 'regional = true',
|
||||||
|
sort: 'start_time',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tournament of regionalTournaments) {
|
||||||
|
const groups = await pb.collection("groups").getFullList({
|
||||||
|
filter: `tournament = "${tournament.id}"`,
|
||||||
|
expand: 'teams,teams.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const teams = group.expand?.teams || [];
|
||||||
|
let playerTeamId: string | null = null;
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
const teamPlayers = team.expand?.players || team.players || [];
|
||||||
|
const isPlayerInTeam = teamPlayers.some((p: any) =>
|
||||||
|
(typeof p === 'string' ? p : p.id) === playerId
|
||||||
|
);
|
||||||
|
if (isPlayerInTeam) {
|
||||||
|
playerTeamId = team.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerTeamId) continue;
|
||||||
|
|
||||||
|
const groupMatches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `tournament = "${tournament.id}" && group = "${group.id}" && status = "ended" && (home = "${playerTeamId}" || away = "${playerTeamId}")`,
|
||||||
|
expand: 'home,away',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupMatches.length === 0) continue;
|
||||||
|
|
||||||
|
let isUndefeated = true;
|
||||||
|
for (const match of groupMatches) {
|
||||||
|
const isHome = match.home === playerTeamId;
|
||||||
|
const didWin = isHome
|
||||||
|
? match.home_cups > match.away_cups
|
||||||
|
: match.away_cups > match.home_cups;
|
||||||
|
|
||||||
|
if (!didWin) {
|
||||||
|
isUndefeated = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUndefeated) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@@ -427,10 +617,13 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
if (criteria.overtime_matches !== undefined) return criteria.overtime_matches;
|
if (criteria.overtime_matches !== undefined) return criteria.overtime_matches;
|
||||||
if (criteria.overtime_wins !== undefined) return criteria.overtime_wins;
|
if (criteria.overtime_wins !== undefined) return criteria.overtime_wins;
|
||||||
if (criteria.consecutive_wins !== undefined) return criteria.consecutive_wins;
|
if (criteria.consecutive_wins !== undefined) return criteria.consecutive_wins;
|
||||||
|
if (criteria.unique_partners !== undefined) return criteria.unique_partners;
|
||||||
if (criteria.won_tournament !== undefined) return 1;
|
if (criteria.won_tournament !== undefined) return 1;
|
||||||
if (criteria.placement !== undefined) return 1;
|
if (criteria.placement !== undefined) return 1;
|
||||||
if (criteria.margin_of_victory !== undefined) return 1;
|
if (criteria.margin_of_victory !== undefined) return 1;
|
||||||
if (criteria.tournament_record !== undefined) return 1;
|
if (criteria.tournament_record !== undefined) return 1;
|
||||||
|
if (criteria.beat_mainline_partner !== undefined) return 1;
|
||||||
|
if (criteria.undefeated_group_stage !== undefined) return 1;
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
},
|
},
|
||||||
|
|||||||
54
src/lib/pocketbase/services/groups.ts
Normal file
54
src/lib/pocketbase/services/groups.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { Group } from "@/features/tournaments/types";
|
||||||
|
|
||||||
|
export interface GroupInput {
|
||||||
|
tournament: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
teams: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroupsService(pb: PocketBase) {
|
||||||
|
return {
|
||||||
|
async createGroup(data: GroupInput): Promise<Group> {
|
||||||
|
logger.info("PocketBase | Creating group", data);
|
||||||
|
const result = await pb.collection("groups").create(data);
|
||||||
|
return result as unknown as Group;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGroupsByTournament(tournamentId: string): Promise<Group[]> {
|
||||||
|
logger.info("PocketBase | Getting groups for tournament", { tournamentId });
|
||||||
|
const result = await pb.collection("groups").getFullList({
|
||||||
|
filter: `tournament = "${tournamentId}"`,
|
||||||
|
sort: "order",
|
||||||
|
expand: "teams,teams.players"
|
||||||
|
});
|
||||||
|
return result as unknown as Group[];
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGroup(groupId: string): Promise<void> {
|
||||||
|
logger.info("PocketBase | Deleting group", { groupId });
|
||||||
|
await pb.collection("groups").delete(groupId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGroupsByTournament(tournamentId: string): Promise<void> {
|
||||||
|
logger.info("PocketBase | Deleting all groups for tournament", { tournamentId });
|
||||||
|
const groups = await pb.collection("groups").getFullList({
|
||||||
|
filter: `tournament = "${tournamentId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
await pb.collection("groups").delete(group.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGroup(groupId: string): Promise<Group> {
|
||||||
|
logger.info("PocketBase | Getting group", { groupId });
|
||||||
|
const result = await pb.collection("groups").getOne(groupId, {
|
||||||
|
expand: "teams,teams.players"
|
||||||
|
});
|
||||||
|
return result as unknown as Group;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -141,5 +141,17 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
|
|
||||||
return results.map(match => transformMatch(match));
|
return results.map(match => transformMatch(match));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMatchesByGroup(groupId: string): Promise<Match[]> {
|
||||||
|
logger.info("PocketBase | Getting matches for group", { groupId });
|
||||||
|
|
||||||
|
const results = await pb.collection("matches").getFullList({
|
||||||
|
filter: `group = "${groupId}"`,
|
||||||
|
expand: "tournament, home, away, home.players, away.players",
|
||||||
|
sort: "created",
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.map(match => transformMatch(match));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,14 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
|
|
||||||
return result.map((match) => transformMatch(match));
|
return result.map((match) => transformMatch(match));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getTeamsWithFilter(filter: string, expand?: string): Promise<any[]> {
|
||||||
|
logger.info("PocketBase | Getting teams with filter", { filter, expand });
|
||||||
|
const result = await pb.collection("teams").getFullList({
|
||||||
|
filter,
|
||||||
|
expand,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,23 @@ import { PlayerInfo } from "@/features/players/types";
|
|||||||
export function createTournamentsService(pb: PocketBase) {
|
export function createTournamentsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
|
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
|
||||||
const [tournamentResult, teamStatsResult] = await Promise.all([
|
const [tournamentResult, teamStatsResult, groupsResult] = await Promise.all([
|
||||||
pb.collection("tournaments").getOne(id, {
|
pb.collection("tournaments").getOne(id, {
|
||||||
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
||||||
}),
|
}),
|
||||||
pb.collection("team_stats_per_tournament").getFullList({
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
filter: `tournament_id = "${id}"`,
|
filter: `tournament_id = "${id}"`,
|
||||||
sort: "-wins,-total_cups_made"
|
sort: "-wins,-total_cups_made"
|
||||||
|
}),
|
||||||
|
pb.collection("groups").getFullList({
|
||||||
|
filter: `tournament = "${id}"`,
|
||||||
|
sort: "order",
|
||||||
|
expand: "teams, teams.players"
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
tournamentResult.team_stats = teamStatsResult;
|
tournamentResult.team_stats = teamStatsResult;
|
||||||
|
tournamentResult.groups = groupsResult;
|
||||||
|
|
||||||
return transformTournament(tournamentResult, isAdmin);
|
return transformTournament(tournamentResult, isAdmin);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
|
|||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
home_seed: record.home_seed,
|
home_seed: record.home_seed,
|
||||||
away_seed: record.away_seed,
|
away_seed: record.away_seed,
|
||||||
|
match_type: record.match_type,
|
||||||
|
group: record.group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +249,16 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groups = (record.groups || record.expand?.groups)?.map((group: any) => ({
|
||||||
|
id: group.id,
|
||||||
|
tournament: group.tournament,
|
||||||
|
name: group.name,
|
||||||
|
order: group.order,
|
||||||
|
teams: group.expand?.teams?.map(transformTeamInfo) ?? [],
|
||||||
|
created: group.created,
|
||||||
|
updated: group.updated,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
@@ -261,8 +273,12 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
regional: record.regional || false,
|
regional: record.regional || false,
|
||||||
|
format: record.format,
|
||||||
|
phase: record.phase,
|
||||||
|
group_config: record.group_config,
|
||||||
teams,
|
teams,
|
||||||
matches,
|
matches,
|
||||||
|
groups,
|
||||||
first_place,
|
first_place,
|
||||||
second_place,
|
second_place,
|
||||||
third_place,
|
third_place,
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ import SuperTokens from "supertokens-node";
|
|||||||
|
|
||||||
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
|
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
|
||||||
ensureSuperTokensBackend();
|
ensureSuperTokensBackend();
|
||||||
|
|
||||||
|
const cookieHeader = request.headers.get('cookie');
|
||||||
|
if (cookieHeader) {
|
||||||
|
const tokens = cookieHeader.match(/sAccessToken=([^;]+)/g);
|
||||||
|
if (tokens && tokens.length > 1) {
|
||||||
|
logger.warn(`Detected ${tokens.length} duplicate sAccessToken cookies, cleaning up`);
|
||||||
|
|
||||||
|
const parsedTokens = tokens.map(tokenStr => {
|
||||||
|
const token = tokenStr.replace('sAccessToken=', '');
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
||||||
|
return { token, exp: payload.exp, iat: payload.iat };
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to parse token', e);
|
||||||
|
return { token, exp: 0, iat: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
parsedTokens.sort((a, b) => b.exp - a.exp);
|
||||||
|
const freshestToken = parsedTokens[0];
|
||||||
|
|
||||||
|
logger.info(`Using freshest token: exp=${freshestToken.exp}, iat=${freshestToken.iat}`);
|
||||||
|
|
||||||
|
const cleanedCookie = cookieHeader
|
||||||
|
.split(';')
|
||||||
|
.filter(c => !c.trim().startsWith('sAccessToken='))
|
||||||
|
.join(';') + `; sAccessToken=${freshestToken.token}`;
|
||||||
|
|
||||||
|
const cleanedHeaders = new Headers(request.headers);
|
||||||
|
cleanedHeaders.set('cookie', cleanedCookie);
|
||||||
|
request = new Request(request, { headers: cleanedHeaders });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await getSessionForSSR(request);
|
const session = await getSessionForSSR(request);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const backendConfig = (): TypeInput => {
|
|||||||
cookieSameSite: "lax",
|
cookieSameSite: "lax",
|
||||||
cookieSecure: process.env.NODE_ENV === "production",
|
cookieSecure: process.env.NODE_ENV === "production",
|
||||||
cookieDomain: process.env.COOKIE_DOMAIN || undefined,
|
cookieDomain: process.env.COOKIE_DOMAIN || undefined,
|
||||||
|
olderCookieDomain: undefined,
|
||||||
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
||||||
|
|
||||||
// Debug only
|
// Debug only
|
||||||
|
|||||||
Reference in New Issue
Block a user