Compare commits
9
Commits
e6c72a5789
...
6565714ee4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6565714ee4 | ||
|
|
1783f0e6bc | ||
|
|
0a7f4de321 | ||
|
|
e8b7647f3d | ||
|
|
0284b33e50 | ||
|
|
d3809b5805 | ||
|
|
a50f9b6644 | ||
|
|
3b087850e7 | ||
|
|
19f61ac454 |
@@ -6,7 +6,7 @@ metadata:
|
|||||||
app: flxn
|
app: flxn
|
||||||
component: app
|
component: app
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1 # Must stay at 1 for SSE
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: flxn
|
app: flxn
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
// Rewrites the player stats views from a cartesian LIKE-join over
|
||||||
|
// players x teams x matches (which forced a full re-scan per row and took
|
||||||
|
// ~1.5-2s per request) to equi-joins driven by matches, with team rosters
|
||||||
|
// expanded once via json_each. Results are byte-identical; each view now
|
||||||
|
// runs in well under 100ms. The unary "+" in the regional filter prevents
|
||||||
|
// the query planner from picking a pathological join order.
|
||||||
|
migrate((app) => {
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_stats");
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_mainline_stats");
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_regional_stats");
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_stats");
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_mainline_stats");
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_regional_stats");
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n tour.regional = true\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/// <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": true,
|
||||||
|
"collectionId": "pbc_340646327",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3177167065",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "tournament",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2153001328",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "picks",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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_1784007613",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_predictions_tournament_player` ON `predictions` (`tournament`, `player`)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "predictions",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1784007613");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -38,10 +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 AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
|
||||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
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 AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
|
||||||
|
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
|
||||||
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'
|
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||||
@@ -193,6 +196,12 @@ const AuthedAdminTournamentsIndexRoute =
|
|||||||
path: '/tournaments/',
|
path: '/tournaments/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedTournamentsIdPredictionsRoute =
|
||||||
|
AuthedTournamentsIdPredictionsRouteImport.update({
|
||||||
|
id: '/tournaments/$id/predictions',
|
||||||
|
path: '/tournaments/$id/predictions',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedTournamentsIdGroupsRoute =
|
const AuthedTournamentsIdGroupsRoute =
|
||||||
AuthedTournamentsIdGroupsRouteImport.update({
|
AuthedTournamentsIdGroupsRouteImport.update({
|
||||||
id: '/tournaments/$id/groups',
|
id: '/tournaments/$id/groups',
|
||||||
@@ -217,6 +226,18 @@ const ApiFilesCollectionRecordIdFileRoute =
|
|||||||
path: '/api/files/$collection/$recordId/$file',
|
path: '/api/files/$collection/$recordId/$file',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedTournamentsIdPredictionsMakeRoute =
|
||||||
|
AuthedTournamentsIdPredictionsMakeRouteImport.update({
|
||||||
|
id: '/tournaments/$id/predictions_/make',
|
||||||
|
path: '/tournaments/$id/predictions/make',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthedTournamentsIdPredictionsPlayerIdRoute =
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
|
||||||
|
id: '/tournaments/$id/predictions_/$playerId',
|
||||||
|
path: '/tournaments/$id/predictions/$playerId',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsRunIdRoute =
|
const AuthedAdminTournamentsRunIdRoute =
|
||||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||||
id: '/tournaments/run/$id',
|
id: '/tournaments/run/$id',
|
||||||
@@ -266,10 +287,13 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/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
|
||||||
|
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -302,10 +326,13 @@ export interface FileRoutesByTo {
|
|||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/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
|
||||||
|
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -341,10 +368,13 @@ export interface FileRoutesById {
|
|||||||
'/_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/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/_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
|
||||||
|
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -380,10 +410,13 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/'
|
| '/tournaments/'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/tournaments/$id/groups'
|
| '/tournaments/$id/groups'
|
||||||
|
| '/tournaments/$id/predictions'
|
||||||
| '/admin/tournaments/'
|
| '/admin/tournaments/'
|
||||||
| '/admin/tournaments/$id/assign-partners'
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/tournaments/$id/predictions/$playerId'
|
||||||
|
| '/tournaments/$id/predictions/make'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/admin/tournaments/$id/'
|
| '/admin/tournaments/$id/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -416,10 +449,13 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/tournaments/$id/groups'
|
| '/tournaments/$id/groups'
|
||||||
|
| '/tournaments/$id/predictions'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
| '/admin/tournaments/$id/assign-partners'
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/tournaments/$id/predictions/$playerId'
|
||||||
|
| '/tournaments/$id/predictions/make'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/admin/tournaments/$id'
|
| '/admin/tournaments/$id'
|
||||||
id:
|
id:
|
||||||
@@ -454,10 +490,13 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/tournaments/'
|
| '/_authed/tournaments/'
|
||||||
| '/_authed/tournaments/$id/bracket'
|
| '/_authed/tournaments/$id/bracket'
|
||||||
| '/_authed/tournaments/$id/groups'
|
| '/_authed/tournaments/$id/groups'
|
||||||
|
| '/_authed/tournaments/$id/predictions'
|
||||||
| '/_authed/admin/tournaments/'
|
| '/_authed/admin/tournaments/'
|
||||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
| '/_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'
|
||||||
|
| '/_authed/tournaments/$id/predictions_/$playerId'
|
||||||
|
| '/_authed/tournaments/$id/predictions_/make'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/_authed/admin/tournaments/$id/'
|
| '/_authed/admin/tournaments/$id/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -686,6 +725,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/tournaments/$id/predictions': {
|
||||||
|
id: '/_authed/tournaments/$id/predictions'
|
||||||
|
path: '/tournaments/$id/predictions'
|
||||||
|
fullPath: '/tournaments/$id/predictions'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/tournaments/$id/groups': {
|
'/_authed/tournaments/$id/groups': {
|
||||||
id: '/_authed/tournaments/$id/groups'
|
id: '/_authed/tournaments/$id/groups'
|
||||||
path: '/tournaments/$id/groups'
|
path: '/tournaments/$id/groups'
|
||||||
@@ -714,6 +760,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_authed/tournaments/$id/predictions_/make': {
|
||||||
|
id: '/_authed/tournaments/$id/predictions_/make'
|
||||||
|
path: '/tournaments/$id/predictions/make'
|
||||||
|
fullPath: '/tournaments/$id/predictions/make'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
|
'/_authed/tournaments/$id/predictions_/$playerId': {
|
||||||
|
id: '/_authed/tournaments/$id/predictions_/$playerId'
|
||||||
|
path: '/tournaments/$id/predictions/$playerId'
|
||||||
|
fullPath: '/tournaments/$id/predictions/$playerId'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/run/$id': {
|
'/_authed/admin/tournaments/run/$id': {
|
||||||
id: '/_authed/admin/tournaments/run/$id'
|
id: '/_authed/admin/tournaments/run/$id'
|
||||||
path: '/tournaments/run/$id'
|
path: '/tournaments/run/$id'
|
||||||
@@ -779,6 +839,9 @@ interface AuthedRouteChildren {
|
|||||||
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
||||||
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
||||||
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||||
@@ -793,6 +856,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
|||||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||||
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||||
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
||||||
|
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRoute:
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRoute,
|
||||||
|
AuthedTournamentsIdPredictionsMakeRoute:
|
||||||
|
AuthedTournamentsIdPredictionsMakeRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteWithChildren =
|
const AuthedRouteWithChildren =
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ 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 { Box, Container, Flex, Skeleton, Stack } 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";
|
||||||
import { Match } from "@/features/matches/types";
|
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
|
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -34,84 +35,14 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
|||||||
pendingComponent: BracketPending,
|
pendingComponent: BracketPending,
|
||||||
});
|
});
|
||||||
|
|
||||||
function BracketPending() {
|
|
||||||
const columns = [4, 2, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="md" px={0}>
|
|
||||||
<Box
|
|
||||||
p={0}
|
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
|
||||||
backgroundSize: "16px 16px",
|
|
||||||
backgroundPosition: "0 0, 8px 8px",
|
|
||||||
minHeight: "70dvh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Skeleton height={18} width={140} radius="sm" m={16} />
|
|
||||||
<Flex gap="xl" px={16} align="stretch">
|
|
||||||
{columns.map((count, columnIndex) => (
|
|
||||||
<Stack
|
|
||||||
key={`bracket-pending-round-${columnIndex}`}
|
|
||||||
gap="xl"
|
|
||||||
justify="space-around"
|
|
||||||
style={{ opacity: 1 - columnIndex * 0.25 }}
|
|
||||||
>
|
|
||||||
{Array.from({ length: count }).map((_, matchIndex) => (
|
|
||||||
<Skeleton
|
|
||||||
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
|
||||||
height={84}
|
|
||||||
width={220}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(() => {
|
const bracket: BracketData = useMemo(
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
() => groupMatchesIntoBracket(tournament.matches),
|
||||||
return { winners: [], losers: [] };
|
[tournament.matches]
|
||||||
}
|
);
|
||||||
|
|
||||||
const winnersMap = new Map<number, Match[]>();
|
|
||||||
const losersMap = new Map<number, Match[]>();
|
|
||||||
|
|
||||||
tournament.matches
|
|
||||||
.filter((match) => match.round !== -1)
|
|
||||||
.sort((a, b) => a.lid - b.lid)
|
|
||||||
.forEach((match) => {
|
|
||||||
if (!match.is_losers_bracket) {
|
|
||||||
if (!winnersMap.has(match.round)) {
|
|
||||||
winnersMap.set(match.round, []);
|
|
||||||
}
|
|
||||||
winnersMap.get(match.round)!.push(match);
|
|
||||||
} else {
|
|
||||||
if (!losersMap.has(match.round)) {
|
|
||||||
losersMap.set(match.round, []);
|
|
||||||
}
|
|
||||||
losersMap.get(match.round)!.push(match);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const winners = Array.from(winnersMap.entries())
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([, matches]) => matches);
|
|
||||||
|
|
||||||
const losers = Array.from(losersMap.entries())
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([, matches]) => matches);
|
|
||||||
return { winners, losers };
|
|
||||||
}, [tournament.matches]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { Container } from "@mantine/core";
|
||||||
|
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
||||||
|
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: () => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: "Predictions",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<PredictionLeaderboard tournament={tournament} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { predictionQueries, usePlayerPrediction } from "@/features/predictions/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||||
|
import { PredictionBracket } from "@/features/predictions/components/prediction-bracket";
|
||||||
|
import { computePredictionScore } from "@/features/predictions/utils";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authed/tournaments/$id/predictions_/$playerId"
|
||||||
|
)({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
|
|
||||||
|
const prediction = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
predictionQueries.player(params.id, params.playerId)
|
||||||
|
);
|
||||||
|
if (!prediction) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/tournaments/$id/predictions",
|
||||||
|
params: { id: params.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
prediction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.prediction.player.first_name}'s Bracket`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
pendingComponent: BracketPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id, playerId } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
const { data: prediction } = usePlayerPrediction(id, playerId);
|
||||||
|
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const picks = prediction?.picks ?? {};
|
||||||
|
|
||||||
|
const score = useMemo(
|
||||||
|
() => computePredictionScore(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<Box pos="relative">
|
||||||
|
<PredictionBracket
|
||||||
|
matches={matches}
|
||||||
|
picks={picks}
|
||||||
|
mode="view"
|
||||||
|
perMatch={score.perMatch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
p="md"
|
||||||
|
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
shadow="md"
|
||||||
|
radius="lg"
|
||||||
|
p="sm"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<PlayerAvatar
|
||||||
|
name={`${prediction?.player.first_name} ${prediction?.player.last_name}`}
|
||||||
|
size={32}
|
||||||
|
disableFullscreen
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{prediction?.player.first_name} {prediction?.player.last_name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PTS
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{score.points}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PICKS
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { predictionQueries, useMyPrediction } from "@/features/predictions/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { Container } from "@mantine/core";
|
||||||
|
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||||
|
import { PredictionEditor } from "@/features/predictions/components/prediction-editor";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authed/tournaments/$id/predictions_/make"
|
||||||
|
)({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
|
|
||||||
|
const myPrediction = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
predictionQueries.mine(params.id)
|
||||||
|
);
|
||||||
|
if (!myPrediction.eligible || myPrediction.locked) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/tournaments/$id/predictions",
|
||||||
|
params: { id: params.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.tournament.name}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
pendingComponent: BracketPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
const { data: myPrediction } = useMyPrediction(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<PredictionEditor
|
||||||
|
tournament={tournament}
|
||||||
|
initialPicks={myPrediction.prediction?.picks ?? {}}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
let activeConnections = 0;
|
let activeConnections = 0;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events/$")({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
server: {
|
server: {
|
||||||
@@ -13,63 +14,47 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
activeConnections++;
|
activeConnections++;
|
||||||
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
||||||
|
|
||||||
|
let cleanedUp = false;
|
||||||
|
let cleanup = () => {};
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const send = (payload: unknown) => {
|
||||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
|
||||||
|
|
||||||
const handleEvent = (event: ServerEvent) => {
|
|
||||||
logger.info("ServerEvents | Event received", event);
|
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
|
||||||
try {
|
try {
|
||||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
||||||
logger.warn("ServerEvents | Stream closed, skipping event");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
serverEvents.on("test", handleEvent);
|
const handleEvent = (event: ServerEvent) => send(event);
|
||||||
serverEvents.on("match", handleEvent);
|
for (const type of EVENT_TYPES) {
|
||||||
serverEvents.on("reaction", handleEvent);
|
serverEvents.on(type, handleEvent);
|
||||||
|
}
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
send({ type: "ping", timestamp: Date.now() });
|
||||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("ServerEvents | Ping interval error", e);
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
}
|
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
setTimeout(() => {
|
cleanup = () => {
|
||||||
try {
|
if (cleanedUp) return;
|
||||||
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
cleanedUp = true;
|
||||||
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("ServerEvents | Heartbeat error", e);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
activeConnections--;
|
activeConnections--;
|
||||||
serverEvents.off("test", handleEvent);
|
for (const type of EVENT_TYPES) {
|
||||||
serverEvents.off("match", handleEvent);
|
serverEvents.off(type, handleEvent);
|
||||||
serverEvents.off("reaction", handleEvent);
|
}
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
return cleanup;
|
|
||||||
|
send({ type: "connected" });
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cleanup();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,13 +62,7 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "Cache-Control",
|
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
"X-Proxy-Buffering": "no",
|
|
||||||
"Proxy-Buffering": "off",
|
|
||||||
"Transfer-Encoding": "chunked",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Paper, Box } from "@mantine/core";
|
import { Box, Avatar as MantineAvatar } from "@mantine/core";
|
||||||
import {
|
|
||||||
Avatar as MantineAvatar,
|
|
||||||
AvatarProps as MantineAvatarProps,
|
|
||||||
} from "@mantine/core";
|
|
||||||
|
|
||||||
interface GlitchAvatarProps
|
interface GlitchAvatarProps {
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
|
||||||
name: string;
|
name: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
glitchSrc?: string;
|
glitchSrc?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
contain?: boolean;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
px?: string | number;
|
|
||||||
frame?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FRAME_PADDING = 8;
|
||||||
|
|
||||||
|
const toCssRadius = (radius: string | number) =>
|
||||||
|
typeof radius === "number" ? `${radius}px` : `var(--mantine-radius-${radius})`;
|
||||||
|
|
||||||
const GlitchAvatar = ({
|
const GlitchAvatar = ({
|
||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
glitchSrc,
|
glitchSrc,
|
||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "md",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
contain = false,
|
|
||||||
children,
|
children,
|
||||||
px,
|
|
||||||
frame = false,
|
|
||||||
...props
|
|
||||||
}: GlitchAvatarProps) => {
|
}: GlitchAvatarProps) => {
|
||||||
const [showGlitch, setShowGlitch] = useState(false);
|
const [showGlitch, setShowGlitch] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -90,98 +83,72 @@ const GlitchAvatar = ({
|
|||||||
});
|
});
|
||||||
}, [showGlitch, isPlaying]);
|
}, [showGlitch, isPlaying]);
|
||||||
|
|
||||||
|
const innerRadius = toCssRadius(radius);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
padding: "8px",
|
|
||||||
borderRadius:
|
|
||||||
typeof radius === "number"
|
|
||||||
? `${radius + 8}px`
|
|
||||||
: "calc(var(--mantine-radius-md) + 8px)",
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
...(frame && {
|
width: "fit-content",
|
||||||
boxShadow:
|
padding: FRAME_PADDING,
|
||||||
"0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), 0 8px 32px -8px color-mix(in srgb, var(--mantine-primary-color-filled) 30%, transparent)",
|
border: withBorder
|
||||||
}),
|
? "1px solid var(--mantine-color-default-border)"
|
||||||
|
: "1px solid transparent",
|
||||||
|
borderRadius: `calc(${innerRadius} + ${FRAME_PADDING}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{src ? (
|
||||||
style={{
|
<Box style={{ position: "relative" }}>
|
||||||
opacity: showGlitch ? 0 : 1,
|
<img
|
||||||
transition: showGlitch
|
|
||||||
? "opacity 0.05s ease-in"
|
|
||||||
: "opacity 0.25s ease-out",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
py={size / 12.5}
|
|
||||||
px={size / 20}
|
|
||||||
bg="var(--mantine-color-default-border)"
|
|
||||||
radius={radius}
|
|
||||||
withBorder={false}
|
|
||||||
style={{
|
|
||||||
cursor: "default",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MantineAvatar
|
|
||||||
alt={name}
|
|
||||||
key={name}
|
|
||||||
name={name}
|
|
||||||
color="initials"
|
|
||||||
size={size}
|
|
||||||
radius={radius}
|
|
||||||
w={size}
|
|
||||||
styles={{
|
|
||||||
image: {
|
|
||||||
objectFit: contain ? "contain" : "cover",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
src={src}
|
src={src}
|
||||||
{...props}
|
alt={name}
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MantineAvatar>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{glitchSrc && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "8px",
|
|
||||||
left: "8px",
|
|
||||||
opacity: showGlitch ? 1 : 0,
|
|
||||||
visibility: showGlitch ? "visible" : "hidden",
|
|
||||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
py={size / 12.5}
|
|
||||||
px={size / 20}
|
|
||||||
bg="var(--mantine-color-default-border)"
|
|
||||||
radius={radius}
|
|
||||||
withBorder={false}
|
|
||||||
style={{
|
style={{
|
||||||
overflow: "hidden",
|
display: "block",
|
||||||
|
maxWidth: size,
|
||||||
|
maxHeight: size,
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
borderRadius: innerRadius,
|
||||||
|
opacity: showGlitch ? 0 : 1,
|
||||||
|
transition: showGlitch
|
||||||
|
? "opacity 0.05s ease-in"
|
||||||
|
: "opacity 0.25s ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
|
{glitchSrc && (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={glitchSrc}
|
src={glitchSrc}
|
||||||
style={{
|
style={{
|
||||||
width: `${size}px`,
|
position: "absolute",
|
||||||
height: `${size}px`,
|
inset: 0,
|
||||||
objectFit: contain ? "contain" : "cover",
|
width: "100%",
|
||||||
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
height: "100%",
|
||||||
display: "block",
|
objectFit: "contain",
|
||||||
|
borderRadius: innerRadius,
|
||||||
|
opacity: showGlitch ? 1 : 0,
|
||||||
|
visibility: showGlitch ? "visible" : "hidden",
|
||||||
|
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
/>
|
/>
|
||||||
</Paper>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineAvatar>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { Icon, IconProps } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
export const WizardOrbIcon = forwardRef<SVGSVGElement, IconProps>(
|
||||||
|
(
|
||||||
|
{ size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const strokeWidth = weight === "bold" ? 20 : 16;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
fill="none"
|
||||||
|
style={{
|
||||||
|
transform: mirrored ? "scale(-1, 1)" : undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="128"
|
||||||
|
cy="112"
|
||||||
|
r="66"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill={weight === "duotone" ? color : "none"}
|
||||||
|
fillOpacity={weight === "duotone" ? 0.18 : undefined}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M94 96 a 44 44 0 0 1 30 -26"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth * 0.7}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M96 194 h64 l16 26 h-96 z"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M208 26 l8 16 16 8 -16 8 -8 16 -8 -16 -16 -8 16 -8 z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
) as Icon;
|
||||||
|
|
||||||
|
export default WizardOrbIcon;
|
||||||
@@ -3,6 +3,7 @@ import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } fro
|
|||||||
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 { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
export const getPlayerBadges = createServerFn()
|
export const getPlayerBadges = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
@@ -14,7 +15,11 @@ export const getPlayerBadges = createServerFn()
|
|||||||
export const migrateBadgeProgress = createServerFn()
|
export const migrateBadgeProgress = createServerFn()
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(() => pbAdmin.migrateBadgeProgress())
|
toServerResult(async () => {
|
||||||
|
const result = await pbAdmin.migrateBadgeProgress();
|
||||||
|
emitServerEvent({ type: "badge" });
|
||||||
|
return result;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAllBadges = createServerFn()
|
export const getAllBadges = createServerFn()
|
||||||
@@ -34,5 +39,9 @@ export const awardManualBadge = createServerFn()
|
|||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId))
|
toServerResult(async () => {
|
||||||
|
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||||
|
emitServerEvent({ type: "badge", playerId: data.playerId });
|
||||||
|
return result;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
export function BracketPending() {
|
||||||
|
const columns = [4, 2, 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<Box
|
||||||
|
p={0}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 8px 8px",
|
||||||
|
minHeight: "70dvh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton height={18} width={140} radius="sm" m={16} />
|
||||||
|
<Flex gap="xl" px={16} align="stretch">
|
||||||
|
{columns.map((count, columnIndex) => (
|
||||||
|
<Stack
|
||||||
|
key={`bracket-pending-round-${columnIndex}`}
|
||||||
|
gap="xl"
|
||||||
|
justify="space-around"
|
||||||
|
style={{ opacity: 1 - columnIndex * 0.25 }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, matchIndex) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
||||||
|
height={84}
|
||||||
|
width={220}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Text, ScrollArea } from "@mantine/core";
|
import { Text, ScrollArea, Box } from "@mantine/core";
|
||||||
import { BracketData } from "../types";
|
import { BracketData } from "../types";
|
||||||
import { Bracket } from "./bracket";
|
import { Bracket } from "./bracket";
|
||||||
|
import MatchDock from "./match-dock";
|
||||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
@@ -13,10 +14,14 @@ interface BracketViewProps {
|
|||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
};
|
};
|
||||||
|
renderMatch?: (match: Match) => React.ReactNode;
|
||||||
|
bottomOffset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
|
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasAutoScrolled = useRef(false);
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
const map: Record<number, number> = {};
|
const map: Record<number, number> = {};
|
||||||
bracket.winners.flat().forEach(match => map[match.lid] = match.order);
|
bracket.winners.flat().forEach(match => map[match.lid] = match.order);
|
||||||
@@ -24,7 +29,60 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
return map;
|
return map;
|
||||||
}, [bracket.winners, bracket.losers]);
|
}, [bracket.winners, bracket.losers]);
|
||||||
|
|
||||||
return <ScrollArea
|
const [selectedLid, setSelectedLid] = useState<number | null>(null);
|
||||||
|
const handleMatchTap = useCallback((match: Match) => {
|
||||||
|
setSelectedLid((prev) => (prev === match.lid ? null : match.lid));
|
||||||
|
}, []);
|
||||||
|
const closeDock = useCallback(() => setSelectedLid(null), []);
|
||||||
|
|
||||||
|
const selectedMatch = useMemo(() => {
|
||||||
|
if (selectedLid == null) return null;
|
||||||
|
return (
|
||||||
|
[...bracket.winners.flat(), ...bracket.losers.flat()].find(
|
||||||
|
(match) => match.lid === selectedLid
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}, [bracket, selectedLid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAutoScrolled.current) return;
|
||||||
|
|
||||||
|
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
|
||||||
|
(match) => !match.bye
|
||||||
|
);
|
||||||
|
const target =
|
||||||
|
matches.find((match) => match.status === "started") ??
|
||||||
|
matches.find((match) => match.status === "ready");
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const element = viewport?.querySelector(`[data-match-lid="${target.lid}"]`);
|
||||||
|
if (!viewport || !element) return;
|
||||||
|
|
||||||
|
hasAutoScrolled.current = true;
|
||||||
|
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const left =
|
||||||
|
elementRect.left - viewportRect.left + viewport.scrollLeft - 40;
|
||||||
|
const top =
|
||||||
|
elementRect.top -
|
||||||
|
viewportRect.top +
|
||||||
|
viewport.scrollTop -
|
||||||
|
(viewport.clientHeight - elementRect.height) / 2;
|
||||||
|
|
||||||
|
viewport.scrollTo({
|
||||||
|
left: Math.max(0, left),
|
||||||
|
top: Math.max(0, top),
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
? "auto"
|
||||||
|
: "smooth",
|
||||||
|
});
|
||||||
|
}, [bracket]);
|
||||||
|
|
||||||
|
return <Box pos="relative">
|
||||||
|
<ScrollArea
|
||||||
|
viewportRef={viewportRef}
|
||||||
h={`calc(${height})`}
|
h={`calc(${height})`}
|
||||||
className={styles["bracket-container"]}
|
className={styles["bracket-container"]}
|
||||||
style={{
|
style={{
|
||||||
@@ -37,17 +95,20 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
<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} groupConfig={groupConfig} />
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
|
||||||
</div>
|
</div>
|
||||||
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
||||||
<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} groupConfig={groupConfig} />
|
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<MatchDock match={selectedMatch} onClose={closeDock} />
|
||||||
|
</Box>
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BracketView;
|
export default BracketView;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ interface BracketProps {
|
|||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
};
|
};
|
||||||
|
renderMatch?: (match: Match) => React.ReactNode;
|
||||||
|
onMatchTap?: (match: Match) => void;
|
||||||
|
selectedMatchLid?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Bracket: React.FC<BracketProps> = ({
|
export const Bracket: React.FC<BracketProps> = ({
|
||||||
@@ -18,6 +21,9 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
|
renderMatch,
|
||||||
|
onMatchTap,
|
||||||
|
selectedMatchLid,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
@@ -133,12 +139,18 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
<div key={match.lid}></div>
|
<div key={match.lid}></div>
|
||||||
) : (
|
) : (
|
||||||
<div key={match.lid}>
|
<div key={match.lid}>
|
||||||
<MatchCard
|
{renderMatch ? (
|
||||||
match={match}
|
renderMatch(match)
|
||||||
orders={orders}
|
) : (
|
||||||
showControls={showControls}
|
<MatchCard
|
||||||
groupConfig={groupConfig}
|
match={match}
|
||||||
/>
|
orders={orders}
|
||||||
|
showControls={showControls}
|
||||||
|
groupConfig={groupConfig}
|
||||||
|
onTap={onMatchTap}
|
||||||
|
selected={selectedMatchLid === match.lid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { endMatch, startMatch } from "@/features/matches/server";
|
|||||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
||||||
|
import { getGroupLabel } from "../utils/group-label";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -21,6 +23,8 @@ interface MatchCardProps {
|
|||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
};
|
};
|
||||||
|
onTap?: (match: Match) => void;
|
||||||
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||||
@@ -28,50 +32,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
|
onTap,
|
||||||
|
selected,
|
||||||
}) => {
|
}) => {
|
||||||
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) => {
|
const canTap = !!(onTap && match.home && match.away);
|
||||||
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 totalQualifiedTeams = numGroups * advancePerGroup;
|
|
||||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
|
|
||||||
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
|
|
||||||
|
|
||||||
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
|
||||||
const wildcardNumber = seed - totalQualifiedTeams;
|
|
||||||
return `Wildcard ${wildcardNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -85,9 +53,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,
|
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed, groupConfig) : undefined,
|
||||||
}),
|
}),
|
||||||
[match, getGroupLabel]
|
[match, orders, groupConfig]
|
||||||
);
|
);
|
||||||
const awaySlot = useMemo(
|
const awaySlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -101,9 +69,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,
|
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed, groupConfig) : undefined,
|
||||||
}),
|
}),
|
||||||
[match, getGroupLabel]
|
[match, orders, groupConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showToolbar = useMemo(
|
const showToolbar = useMemo(
|
||||||
@@ -273,10 +241,31 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
w={showToolbar || showEditButton ? 200 : 220}
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
|
className={canTap ? styles["tappable-card"] : undefined}
|
||||||
|
onClick={canTap ? () => onTap!(match) : undefined}
|
||||||
|
role={canTap ? "button" : undefined}
|
||||||
|
tabIndex={canTap ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canTap
|
||||||
|
? (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onTap!(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
canTap
|
||||||
|
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
borderColor: 'var(--mantine-color-default-border)',
|
borderColor: selected
|
||||||
|
? 'var(--mantine-primary-color-filled)'
|
||||||
|
: 'var(--mantine-color-default-border)',
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
}}
|
}}
|
||||||
data-match-lid={match.lid}
|
data-match-lid={match.lid}
|
||||||
@@ -310,7 +299,10 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={handleSpeakerClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSpeakerClick();
|
||||||
|
}}
|
||||||
aria-label="Announce matchup"
|
aria-label="Announce matchup"
|
||||||
>
|
>
|
||||||
<SpeakerHighIcon size={12} />
|
<SpeakerHighIcon size={12} />
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
CloseButton,
|
||||||
|
Group,
|
||||||
|
Indicator,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { FootballHelmetIcon } from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||||
|
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||||
|
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
|
||||||
|
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||||
|
|
||||||
|
interface MatchDockProps {
|
||||||
|
match: Match | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamRow = ({
|
||||||
|
team,
|
||||||
|
cups,
|
||||||
|
isWinner,
|
||||||
|
isRegional,
|
||||||
|
ended,
|
||||||
|
}: {
|
||||||
|
team: NonNullable<Match["home"]>;
|
||||||
|
cups: number;
|
||||||
|
isWinner: boolean;
|
||||||
|
isRegional: boolean;
|
||||||
|
ended: boolean;
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ flex: 1, minWidth: 0, cursor: "pointer" }}
|
||||||
|
onClick={() => navigate({ to: `/teams/${team.id}` })}
|
||||||
|
>
|
||||||
|
<TeamAvatar
|
||||||
|
team={team}
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
winner={ended && isWinner}
|
||||||
|
isRegional={isRegional}
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={ended && isWinner ? 700 : 500} lineClamp={1}>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{ended && (
|
||||||
|
<AnimatedScore
|
||||||
|
value={cups}
|
||||||
|
size="lg"
|
||||||
|
fw={700}
|
||||||
|
c={isWinner ? "green" : "dimmed"}
|
||||||
|
style={{ minWidth: 28, textAlign: "center" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const h2hSheet = useSheet();
|
||||||
|
const hasPrivate = match?.home?.private || match?.away?.private;
|
||||||
|
const ended = match?.status === "ended";
|
||||||
|
const started = match?.status === "started";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{match && match.home && match.away && (
|
||||||
|
<motion.div
|
||||||
|
key="match-dock"
|
||||||
|
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
|
||||||
|
transition={{ duration: 0.2, ease: EASE }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 5,
|
||||||
|
padding: "0 12px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper withBorder shadow="md" radius="lg" px="md" py="sm">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
{started && (
|
||||||
|
<Indicator
|
||||||
|
size={8}
|
||||||
|
color="red"
|
||||||
|
processing
|
||||||
|
position="middle-start"
|
||||||
|
offset={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
|
||||||
|
Match {match.order} · Round {match.round + 1}
|
||||||
|
{match.is_losers_bracket && " (Losers)"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close match actions"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TeamRow
|
||||||
|
team={match.home}
|
||||||
|
cups={match.home_cups}
|
||||||
|
isWinner={match.home_cups > match.away_cups}
|
||||||
|
isRegional={match.tournament.regional === true}
|
||||||
|
ended={ended}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "var(--mantine-color-default-border)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TeamRow
|
||||||
|
team={match.away}
|
||||||
|
cups={match.away_cups}
|
||||||
|
isWinner={match.away_cups > match.home_cups}
|
||||||
|
isRegional={match.tournament.regional === true}
|
||||||
|
ended={ended}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center py={4}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmojiBar matchId={match.id} />
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
{!hasPrivate && (
|
||||||
|
<Tooltip label="Head to Head" withArrow position="top">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={h2hSheet.open}
|
||||||
|
aria-label="View head-to-head"
|
||||||
|
w={40}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
style={{ position: "relative", width: 27.5, height: 16 }}
|
||||||
|
>
|
||||||
|
<FootballHelmetIcon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
transform: "rotate(25deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FootballHelmetIcon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
transform: "scaleX(-1) rotate(25deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{match?.home && match?.away && h2hSheet.isOpen && (
|
||||||
|
<Sheet title="Head to Head" {...h2hSheet.props}>
|
||||||
|
<TeamHeadToHeadSheet
|
||||||
|
team1={match.home}
|
||||||
|
team2={match.away}
|
||||||
|
isOpen={h2hSheet.props.opened}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchDock;
|
||||||
@@ -6,6 +6,8 @@ import { TeamInfo } from "@/features/teams/types";
|
|||||||
import AnimatedScore from "@/features/matches/components/animated-score";
|
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||||
import classes from "./match-slot.module.css";
|
import classes from "./match-slot.module.css";
|
||||||
|
|
||||||
|
export type MatchSlotState = "winner" | "correct" | "incorrect";
|
||||||
|
|
||||||
interface MatchSlotProps {
|
interface MatchSlotProps {
|
||||||
from?: number;
|
from?: number;
|
||||||
from_loser?: boolean;
|
from_loser?: boolean;
|
||||||
@@ -14,6 +16,7 @@ interface MatchSlotProps {
|
|||||||
cups?: number;
|
cups?: number;
|
||||||
isWinner?: boolean;
|
isWinner?: boolean;
|
||||||
groupLabel?: string;
|
groupLabel?: string;
|
||||||
|
state?: MatchSlotState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||||
@@ -23,7 +26,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
seed,
|
seed,
|
||||||
cups,
|
cups,
|
||||||
isWinner,
|
isWinner,
|
||||||
groupLabel
|
groupLabel,
|
||||||
|
state,
|
||||||
}) => {
|
}) => {
|
||||||
const teamId = team?.id;
|
const teamId = team?.id;
|
||||||
const previousTeamIdRef = useRef<string | undefined>(teamId);
|
const previousTeamIdRef = useRef<string | undefined>(teamId);
|
||||||
@@ -37,11 +41,19 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
}
|
}
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
||||||
|
const slotState: MatchSlotState | undefined =
|
||||||
|
state ?? (isWinner ? "winner" : undefined);
|
||||||
|
const highlighted = slotState === "winner" || slotState === "correct";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
align="stretch"
|
align="stretch"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
backgroundColor: highlighted
|
||||||
|
? 'var(--mantine-color-green-light)'
|
||||||
|
: slotState === "incorrect"
|
||||||
|
? 'var(--mantine-color-red-light)'
|
||||||
|
: 'transparent',
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
transition: 'background-color 200ms ease',
|
transition: 'background-color 200ms ease',
|
||||||
}}
|
}}
|
||||||
@@ -60,11 +72,12 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
<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
|
||||||
|
c={slotState === "incorrect" ? "dimmed" : undefined}
|
||||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Text>
|
</Text>
|
||||||
{isWinner && (
|
{highlighted && (
|
||||||
<CrownIcon
|
<CrownIcon
|
||||||
size={14}
|
size={14}
|
||||||
weight="fill"
|
weight="fill"
|
||||||
|
|||||||
@@ -6,4 +6,20 @@
|
|||||||
.bracket-container {
|
.bracket-container {
|
||||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tappable-card {
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.tappable-card {
|
||||||
|
transition: transform 120ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||||
|
border-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tappable-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export interface GroupConfig {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupLabel(
|
||||||
|
seed: number | undefined,
|
||||||
|
groupConfig: GroupConfig | undefined
|
||||||
|
): string | 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 totalQualifiedTeams = numGroups * advancePerGroup;
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
|
||||||
|
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
|
||||||
|
|
||||||
|
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
||||||
|
const wildcardNumber = seed - totalQualifiedTeams;
|
||||||
|
return `Wildcard ${wildcardNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { BracketData } from "../types";
|
||||||
|
|
||||||
|
export const groupMatchesIntoBracket = (matches?: Match[]): BracketData => {
|
||||||
|
if (!matches || matches.length === 0) {
|
||||||
|
return { winners: [], losers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnersMap = new Map<number, Match[]>();
|
||||||
|
const losersMap = new Map<number, Match[]>();
|
||||||
|
|
||||||
|
matches
|
||||||
|
.filter((match) => match.round !== -1)
|
||||||
|
.sort((a, b) => a.lid - b.lid)
|
||||||
|
.forEach((match) => {
|
||||||
|
if (!match.is_losers_bracket) {
|
||||||
|
if (!winnersMap.has(match.round)) {
|
||||||
|
winnersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
winnersMap.get(match.round)!.push(match);
|
||||||
|
} else {
|
||||||
|
if (!losersMap.has(match.round)) {
|
||||||
|
losersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
losersMap.get(match.round)!.push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const winners = Array.from(winnersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
|
||||||
|
const losers = Array.from(losersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
return { winners, losers };
|
||||||
|
};
|
||||||
@@ -7,11 +7,9 @@ const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
|
|||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
display={collapsed ? 'none' : 'flex'}
|
display={collapsed ? 'none' : 'flex'}
|
||||||
withBorder={false}
|
|
||||||
style={{
|
style={{
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
transition: 'border-color 200ms ease-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
shadow='md'
|
shadow='md'
|
||||||
withBorder
|
|
||||||
p='md'
|
p='md'
|
||||||
w='100%'
|
w='100%'
|
||||||
maw='375px'
|
maw='375px'
|
||||||
@@ -37,8 +36,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
<Stack align='center' gap='xs' mb='md'>
|
<Stack align='center' gap='xs' mb='md'>
|
||||||
<GlitchAvatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
|
||||||
frame
|
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -51,7 +48,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={250}
|
size={250}
|
||||||
px="xs"
|
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 { Match, MatchInput } from "@/features/matches/types";
|
import { Match, MatchInput } from "@/features/matches/types";
|
||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
@@ -129,6 +129,8 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
matchCount: createdMatches.length,
|
matchCount: createdMatches.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
matchCount: createdMatches.length,
|
matchCount: createdMatches.length,
|
||||||
@@ -154,7 +156,7 @@ export const startMatch = createServerFn()
|
|||||||
status: "started",
|
status: "started",
|
||||||
});
|
});
|
||||||
|
|
||||||
serverEvents.emit("match", {
|
emitServerEvent({
|
||||||
type: "match",
|
type: "match",
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
tournamentId: match.tournament.id
|
tournamentId: match.tournament.id
|
||||||
@@ -180,7 +182,9 @@ export const populateKnockoutBracket = createServerFn()
|
|||||||
throw new Error("Tournament must have group_config");
|
throw new Error("Tournament must have group_config");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
|
const result = await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -479,7 +483,7 @@ export const endMatch = createServerFn()
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (match.lid === -1) {
|
if (match.lid === -1) {
|
||||||
serverEvents.emit("match", {
|
emitServerEvent({
|
||||||
type: "match",
|
type: "match",
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
tournamentId: match.tournament.id
|
tournamentId: match.tournament.id
|
||||||
@@ -504,6 +508,11 @@ export const endMatch = createServerFn()
|
|||||||
});
|
});
|
||||||
|
|
||||||
await pbAdmin.deleteMatch(winner.id);
|
await pbAdmin.deleteMatch(winner.id);
|
||||||
|
emitServerEvent({
|
||||||
|
type: "match",
|
||||||
|
matchId: match.id,
|
||||||
|
tournamentId: match.tournament.id
|
||||||
|
});
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,7 +539,7 @@ export const endMatch = createServerFn()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
serverEvents.emit("match", {
|
emitServerEvent({
|
||||||
type: "match",
|
type: "match",
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
tournamentId: match.tournament.id
|
tournamentId: match.tournament.id
|
||||||
@@ -590,7 +599,7 @@ export const toggleMatchReaction = createServerFn()
|
|||||||
|
|
||||||
const reactions = Object.values(reactionsByEmoji);
|
const reactions = Object.values(reactionsByEmoji);
|
||||||
|
|
||||||
serverEvents.emit("reaction", {
|
emitServerEvent({
|
||||||
type: "reaction",
|
type: "reaction",
|
||||||
matchId,
|
matchId,
|
||||||
reactions,
|
reactions,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const updatePlayer = createServerFn()
|
|||||||
|
|
||||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
||||||
|
|
||||||
|
const { emitServerEvent } = await import("@/lib/events/emitter");
|
||||||
|
emitServerEvent({ type: "player", playerId: existing.id! });
|
||||||
|
|
||||||
return updatedPlayer;
|
return updatedPlayer;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -95,6 +98,12 @@ export const createPlayer = createServerFn()
|
|||||||
|
|
||||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
||||||
logger.info('Created player', newPlayer);
|
logger.info('Created player', newPlayer);
|
||||||
|
|
||||||
|
if (newPlayer?.id) {
|
||||||
|
const { emitServerEvent } = await import("@/lib/events/emitter");
|
||||||
|
emitServerEvent({ type: "player", playerId: newPlayer.id });
|
||||||
|
}
|
||||||
|
|
||||||
return newPlayer;
|
return newPlayer;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -123,6 +132,10 @@ export const associatePlayer = createServerFn()
|
|||||||
|
|
||||||
const player = await pbAdmin.getPlayer(data);
|
const player = await pbAdmin.getPlayer(data);
|
||||||
logger.info('Associated player', player);
|
logger.info('Associated player', player);
|
||||||
|
|
||||||
|
const { emitServerEvent } = await import("@/lib/events/emitter");
|
||||||
|
emitServerEvent({ type: "player", playerId: data });
|
||||||
|
|
||||||
return player;
|
return player;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||||
|
|
||||||
|
interface MatchupSheetProps {
|
||||||
|
home?: TeamInfo;
|
||||||
|
away?: TeamInfo;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamRow = ({ team }: { team: TeamInfo }) => (
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<TeamAvatar team={team} size={40} radius="sm" />
|
||||||
|
<Text size="sm" fw={600} lineClamp={2}>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={4} align="flex-end">
|
||||||
|
{team.players?.map((player) => {
|
||||||
|
const name = `${player.first_name} ${player.last_name}`;
|
||||||
|
return (
|
||||||
|
<Group key={player.id} gap={6} wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<PlayerAvatar name={name} size={20} disableFullscreen />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MatchupSheet: React.FC<MatchupSheetProps> = ({
|
||||||
|
home,
|
||||||
|
away,
|
||||||
|
isOpen,
|
||||||
|
}) => {
|
||||||
|
if (!home && !away) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
Pick the earlier matches first — these teams aren't decided yet.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper p="md" withBorder radius="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{home ? (
|
||||||
|
<TeamRow team={home} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Home team TBD — pick the earlier matches first
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Divider label="vs" labelPosition="center" />
|
||||||
|
{away ? (
|
||||||
|
<TeamRow team={away} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Away team TBD — pick the earlier matches first
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{home && away && (
|
||||||
|
<TeamHeadToHeadSheet team1={home} team2={away} isOpen={isOpen} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
|
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { PicksMap } from "../types";
|
||||||
|
import { PickResult, resolvePredictedBracket } from "../utils";
|
||||||
|
import { PredictionMatchCard } from "./prediction-match-card";
|
||||||
|
|
||||||
|
interface PredictionBracketProps {
|
||||||
|
matches: Match[];
|
||||||
|
picks: PicksMap;
|
||||||
|
mode: "edit" | "view";
|
||||||
|
perMatch?: Map<number, PickResult>;
|
||||||
|
activeLid?: number;
|
||||||
|
onActivate?: (lid: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PredictionBracket: React.FC<PredictionBracketProps> = ({
|
||||||
|
matches,
|
||||||
|
picks,
|
||||||
|
mode,
|
||||||
|
perMatch,
|
||||||
|
activeLid,
|
||||||
|
onActivate,
|
||||||
|
}) => {
|
||||||
|
const bracket = useMemo(() => groupMatchesIntoBracket(matches), [matches]);
|
||||||
|
|
||||||
|
const resolved = useMemo(
|
||||||
|
() => resolvePredictedBracket(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const orders = useMemo(() => {
|
||||||
|
const map: Record<number, number> = {};
|
||||||
|
bracket.winners.flat().forEach((match) => (map[match.lid] = match.order));
|
||||||
|
bracket.losers.flat().forEach((match) => (map[match.lid] = match.order));
|
||||||
|
return map;
|
||||||
|
}, [bracket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BracketView
|
||||||
|
bracket={bracket}
|
||||||
|
bottomOffset={110}
|
||||||
|
renderMatch={(match) => (
|
||||||
|
<PredictionMatchCard
|
||||||
|
match={match}
|
||||||
|
resolved={resolved.get(match.lid)}
|
||||||
|
orders={orders}
|
||||||
|
mode={mode}
|
||||||
|
result={perMatch?.get(match.lid)}
|
||||||
|
active={activeLid === match.lid}
|
||||||
|
onActivate={
|
||||||
|
onActivate &&
|
||||||
|
(!match.reset || resolved.get(match.lid)?.resetNecessary)
|
||||||
|
? () => onActivate(match.lid)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { PicksMap } from "../types";
|
||||||
|
import { useSubmitPrediction } from "../queries";
|
||||||
|
import {
|
||||||
|
getMatchLabel,
|
||||||
|
getPickableMatches,
|
||||||
|
resolvePredictedBracket,
|
||||||
|
setPick,
|
||||||
|
} from "../utils";
|
||||||
|
import { PredictionBracket } from "./prediction-bracket";
|
||||||
|
import { MatchupSheet } from "./matchup-sheet";
|
||||||
|
import { WinnerSelector } from "./winner-selector";
|
||||||
|
|
||||||
|
interface PredictionEditorProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
initialPicks: PicksMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PredictionEditor: React.FC<PredictionEditorProps> = ({
|
||||||
|
tournament,
|
||||||
|
initialPicks,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const matchupSheet = useSheet();
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const [picks, setPicks] = useState<PicksMap>(initialPicks);
|
||||||
|
|
||||||
|
const pickable = useMemo(
|
||||||
|
() => getPickableMatches(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = useMemo(
|
||||||
|
() => resolvePredictedBracket(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstUnpickedLid = useMemo(
|
||||||
|
() =>
|
||||||
|
pickable.find((match) => !resolved.get(match.lid)?.pickedWinnerId)?.lid,
|
||||||
|
[pickable, resolved]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeLid, setActiveLid] = useState<number | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLid === undefined && pickable.length > 0) {
|
||||||
|
setActiveLid(firstUnpickedLid ?? pickable[0].lid);
|
||||||
|
}
|
||||||
|
}, [activeLid, firstUnpickedLid, pickable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLid === undefined) return;
|
||||||
|
const card = containerRef.current?.querySelector(
|
||||||
|
`[data-match-lid="${activeLid}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const viewport = card?.closest(
|
||||||
|
".mantine-ScrollArea-viewport"
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!card || !viewport) return;
|
||||||
|
|
||||||
|
const cardRect = card.getBoundingClientRect();
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
viewport.scrollTo({
|
||||||
|
left: Math.max(
|
||||||
|
0,
|
||||||
|
viewport.scrollLeft +
|
||||||
|
(cardRect.left - viewportRect.left) -
|
||||||
|
(viewportRect.width - cardRect.width) / 2
|
||||||
|
),
|
||||||
|
top: Math.max(
|
||||||
|
0,
|
||||||
|
viewport.scrollTop +
|
||||||
|
(cardRect.top - viewportRect.top) -
|
||||||
|
(viewportRect.height - cardRect.height) / 2
|
||||||
|
),
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [activeLid]);
|
||||||
|
|
||||||
|
const pickedCount = pickable.filter(
|
||||||
|
(match) => resolved.get(match.lid)?.pickedWinnerId
|
||||||
|
).length;
|
||||||
|
const complete = pickedCount === pickable.length;
|
||||||
|
|
||||||
|
const submit = useSubmitPrediction(tournament.id);
|
||||||
|
|
||||||
|
const handlePick = (lid: number, teamId: string) => {
|
||||||
|
const next = setPick(matches, picks, lid, teamId);
|
||||||
|
setPicks(next);
|
||||||
|
|
||||||
|
const nextResolved = resolvePredictedBracket(matches, next);
|
||||||
|
const nextPickable = getPickableMatches(matches, next);
|
||||||
|
const nextUnpicked = nextPickable.find(
|
||||||
|
(match) => !nextResolved.get(match.lid)?.pickedWinnerId
|
||||||
|
);
|
||||||
|
setActiveLid(nextUnpicked?.lid ?? lid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeIndex = pickable.findIndex((match) => match.lid === activeLid);
|
||||||
|
const stepTo = (offset: number) => {
|
||||||
|
const next = pickable[activeIndex + offset];
|
||||||
|
if (next) setActiveLid(next.lid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeResolved =
|
||||||
|
activeLid !== undefined ? resolved.get(activeLid) : undefined;
|
||||||
|
const activeMatch = pickable[activeIndex];
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await submit.mutateAsync({
|
||||||
|
data: { tournamentId: tournament.id, picks },
|
||||||
|
});
|
||||||
|
navigate({ to: "/" });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative" ref={containerRef}>
|
||||||
|
<PredictionBracket
|
||||||
|
matches={matches}
|
||||||
|
picks={picks}
|
||||||
|
mode="edit"
|
||||||
|
activeLid={activeLid}
|
||||||
|
onActivate={setActiveLid}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
p="md"
|
||||||
|
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
shadow="md"
|
||||||
|
radius="lg"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
borderColor: "var(--mantine-primary-color-filled)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{activeMatch && (
|
||||||
|
<>
|
||||||
|
<Group gap={6} align="center" wrap="nowrap">
|
||||||
|
<WizardOrbIcon
|
||||||
|
size={16}
|
||||||
|
weight="duotone"
|
||||||
|
color="var(--mantine-primary-color-filled)"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={700}
|
||||||
|
tt="uppercase"
|
||||||
|
c="var(--mantine-primary-color-filled)"
|
||||||
|
style={{ letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
|
{getMatchLabel(matches, activeMatch)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<WinnerSelector
|
||||||
|
home={activeResolved?.home}
|
||||||
|
away={activeResolved?.away}
|
||||||
|
pickedId={activeResolved?.pickedWinnerId}
|
||||||
|
onSelect={(teamId) => handlePick(activeMatch.lid, teamId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap={4} align="center" wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => stepTo(-1)}
|
||||||
|
disabled={activeIndex <= 0}
|
||||||
|
aria-label="Previous match"
|
||||||
|
>
|
||||||
|
<CaretLeftIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => stepTo(1)}
|
||||||
|
disabled={activeIndex < 0 || activeIndex >= pickable.length - 1}
|
||||||
|
aria-label="Next match"
|
||||||
|
>
|
||||||
|
<CaretRightIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={matchupSheet.open}
|
||||||
|
disabled={!activeResolved?.home && !activeResolved?.away}
|
||||||
|
aria-label="Matchup details"
|
||||||
|
>
|
||||||
|
<InfoIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600} c={complete ? undefined : "dimmed"}>
|
||||||
|
{pickedCount}/{pickable.length}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
disabled={!complete}
|
||||||
|
loading={submit.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
title={activeMatch ? getMatchLabel(matches, activeMatch) : "Matchup"}
|
||||||
|
{...matchupSheet.props}
|
||||||
|
>
|
||||||
|
<MatchupSheet
|
||||||
|
home={activeResolved?.home}
|
||||||
|
away={activeResolved?.away}
|
||||||
|
isOpen={matchupSheet.isOpen}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { CrownIcon, InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { predictionQueries, usePredictionsLeaderboard } from "../queries";
|
||||||
|
import { isPredictionLocked, isTournamentPredictable } from "../utils";
|
||||||
|
|
||||||
|
interface PredictionLeaderboardProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
|
||||||
|
tournament,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: leaderboard } = usePredictionsLeaderboard(tournament.id);
|
||||||
|
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const isComplete = useMemo(() => {
|
||||||
|
const nonByeMatches = matches.filter(
|
||||||
|
(match) => !(match.status === "tbd" && match.bye === true)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
nonByeMatches.length > 0 &&
|
||||||
|
nonByeMatches.every((match) => match.status === "ended")
|
||||||
|
);
|
||||||
|
}, [matches]);
|
||||||
|
|
||||||
|
const predictionsOpen =
|
||||||
|
isTournamentPredictable(tournament) && !isPredictionLocked(matches);
|
||||||
|
|
||||||
|
const { data: myPrediction } = useServerQuery({
|
||||||
|
...predictionQueries.mine(tournament.id),
|
||||||
|
options: { enabled: !leaderboard.locked && predictionsOpen },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leaderboard.locked) {
|
||||||
|
const cta = predictionsOpen ? (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/tournaments/$id/predictions/make",
|
||||||
|
params: { id: tournament.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{myPrediction?.prediction
|
||||||
|
? "Edit Your Prediction"
|
||||||
|
: "Make Your Prediction"}
|
||||||
|
</Button>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<WizardOrbIcon
|
||||||
|
size={56}
|
||||||
|
weight="duotone"
|
||||||
|
color="var(--mantine-primary-color-filled)"
|
||||||
|
/>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<Title order={3} c="dimmed" ta="center">
|
||||||
|
Predictions are open
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||||
|
Other players' predictions are hidden until the tournament
|
||||||
|
starts.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{cta}
|
||||||
|
</Stack>
|
||||||
|
{leaderboard.submitters.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text px="md" size="sm" fw={600} pb="xs">
|
||||||
|
{leaderboard.count} bracket{leaderboard.count === 1 ? "" : "s"} in
|
||||||
|
</Text>
|
||||||
|
{leaderboard.submitters.map((player, index) => {
|
||||||
|
const name = `${player.first_name} ${player.last_name}`;
|
||||||
|
return (
|
||||||
|
<Box key={player.id}>
|
||||||
|
<Group gap="sm" align="center" p="md" wrap="nowrap">
|
||||||
|
<PlayerAvatar name={name} size={32} disableFullscreen />
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{index < leaderboard.submitters.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaderboard.entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<WizardOrbIcon
|
||||||
|
size={56}
|
||||||
|
weight="duotone"
|
||||||
|
color="var(--mantine-primary-color-filled)"
|
||||||
|
/>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<Title order={3} c="dimmed" ta="center">
|
||||||
|
No predictions
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||||
|
Nobody made a prediction for this tournament.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group px="md" justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
Predictions
|
||||||
|
</Text>
|
||||||
|
<Popover position="bottom-end" withArrow shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
aria-label="How prediction scoring works"
|
||||||
|
>
|
||||||
|
<InfoIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Box maw={280}>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
Prediction Scoring:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• Each correct pick earns points, doubling every round
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>Winners bracket:</strong> 10, 20, 40, 80…
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>Losers bracket:</strong> 5, 10, 20, 40…
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>Bracket reset:</strong> only picked if your bracket
|
||||||
|
triggers it — worth double the Final
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
Tiebreakers:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
1. Correct champion pick
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
2. Earlier submission
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
|
* PICKS shows correct picks / total picks made
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</Group>
|
||||||
|
<Text px="md" c="dimmed" size="xs" fw={500}>
|
||||||
|
Correct picks are worth more each round
|
||||||
|
</Text>
|
||||||
|
{leaderboard.entries.map((entry, index) => {
|
||||||
|
const name = `${entry.player.first_name} ${entry.player.last_name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={entry.player.id}>
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
style={{ borderRadius: 0 }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/tournaments/$id/predictions/$playerId",
|
||||||
|
params: { id: tournament.id, playerId: entry.player.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" w="100%" wrap="nowrap">
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<PlayerAvatar name={name} size={40} disableFullscreen />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
#{index + 1}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{index === 0 && isComplete && (
|
||||||
|
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
||||||
|
<CrownIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{entry.championPick && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<CrownIcon
|
||||||
|
size={12}
|
||||||
|
weight="fill"
|
||||||
|
color={
|
||||||
|
entry.championCorrect && isComplete
|
||||||
|
? "gold"
|
||||||
|
: "var(--mantine-color-dimmed)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{entry.championPick.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PTS
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{entry.points}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PICKS
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{entry.correct}/{entry.total}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
{index < leaderboard.entries.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Card, Flex, Text } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { MatchSlot, MatchSlotState } from "@/features/bracket/components/match-slot";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { PickResult, ResolvedMatch } from "../utils";
|
||||||
|
|
||||||
|
interface PredictionMatchCardProps {
|
||||||
|
match: Match;
|
||||||
|
resolved?: ResolvedMatch;
|
||||||
|
orders: Record<number, number>;
|
||||||
|
mode: "edit" | "view";
|
||||||
|
result?: PickResult;
|
||||||
|
active?: boolean;
|
||||||
|
onActivate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickedState = (mode: "edit" | "view", result?: PickResult): MatchSlotState => {
|
||||||
|
if (mode === "edit") return "winner";
|
||||||
|
if (result === "correct") return "correct";
|
||||||
|
if (result === "incorrect") return "incorrect";
|
||||||
|
return "winner";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PredictionMatchCard: React.FC<PredictionMatchCardProps> = ({
|
||||||
|
match,
|
||||||
|
resolved,
|
||||||
|
orders,
|
||||||
|
mode,
|
||||||
|
result,
|
||||||
|
active,
|
||||||
|
onActivate,
|
||||||
|
}) => {
|
||||||
|
const resetLive = !match.reset || !!resolved?.resetNecessary;
|
||||||
|
|
||||||
|
const slotProps = (side: "home" | "away") => {
|
||||||
|
const team = side === "home" ? resolved?.home : resolved?.away;
|
||||||
|
const teamId = side === "home" ? resolved?.homeId : resolved?.awayId;
|
||||||
|
const isPicked =
|
||||||
|
!!teamId && resetLive && resolved?.pickedWinnerId === teamId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: orders[side === "home" ? match.home_from_lid : match.away_from_lid],
|
||||||
|
from_loser:
|
||||||
|
side === "home" ? match.home_from_loser : match.away_from_loser,
|
||||||
|
team,
|
||||||
|
seed: side === "home" ? match.home_seed : match.away_seed,
|
||||||
|
state: isPicked ? pickedState(mode, result) : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
align="center"
|
||||||
|
justify="end"
|
||||||
|
gap={8}
|
||||||
|
opacity={resetLive ? 1 : 0.55}
|
||||||
|
style={{ transition: "opacity 200ms ease" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
fw="bolder"
|
||||||
|
px={6}
|
||||||
|
py={2}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.order}
|
||||||
|
</Text>
|
||||||
|
<Card
|
||||||
|
w={220}
|
||||||
|
withBorder
|
||||||
|
pos="relative"
|
||||||
|
onClick={onActivate}
|
||||||
|
style={{
|
||||||
|
cursor: onActivate ? "pointer" : undefined,
|
||||||
|
overflow: "visible",
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderColor: active
|
||||||
|
? 'var(--mantine-primary-color-filled)'
|
||||||
|
: 'var(--mantine-color-default-border)',
|
||||||
|
boxShadow: active
|
||||||
|
? '0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover), var(--mantine-shadow-sm)'
|
||||||
|
: 'var(--mantine-shadow-sm)',
|
||||||
|
transition: 'border-color 200ms ease, box-shadow 200ms ease',
|
||||||
|
}}
|
||||||
|
data-match-lid={match.lid}
|
||||||
|
>
|
||||||
|
<Card.Section withBorder p={0}>
|
||||||
|
<MatchSlot {...slotProps("home")} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Card.Section p={0} mb={-16}>
|
||||||
|
<MatchSlot {...slotProps("away")} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
{match.reset && (
|
||||||
|
<Text
|
||||||
|
pos="absolute"
|
||||||
|
top={-20}
|
||||||
|
left={8}
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw="bold"
|
||||||
|
>
|
||||||
|
* If necessary
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Group, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
import { CrownIcon } from "@phosphor-icons/react";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
|
||||||
|
interface WinnerSelectorProps {
|
||||||
|
home?: TeamInfo;
|
||||||
|
away?: TeamInfo;
|
||||||
|
pickedId?: string;
|
||||||
|
onSelect: (teamId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamChip = ({
|
||||||
|
team,
|
||||||
|
picked,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
team?: TeamInfo;
|
||||||
|
picked: boolean;
|
||||||
|
onSelect: (teamId: string) => void;
|
||||||
|
}) => {
|
||||||
|
if (!team) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
justify="center"
|
||||||
|
p="6px 10px"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: "1px dashed var(--mantine-color-default-border)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
TBD
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => onSelect(team.id)}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
aria-pressed={picked}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
p="6px 10px"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${
|
||||||
|
picked
|
||||||
|
? "var(--mantine-color-green-light-color)"
|
||||||
|
: "var(--mantine-color-default-border)"
|
||||||
|
}`,
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
backgroundColor: picked
|
||||||
|
? "var(--mantine-color-green-light)"
|
||||||
|
: "transparent",
|
||||||
|
transition: "background-color 200ms ease, border-color 200ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamAvatar team={team} size={26} radius="sm" disableFullscreen />
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
truncate
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
{picked && (
|
||||||
|
<CrownIcon
|
||||||
|
size={14}
|
||||||
|
weight="fill"
|
||||||
|
style={{
|
||||||
|
color: "gold",
|
||||||
|
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.3))",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WinnerSelector: React.FC<WinnerSelectorProps> = ({
|
||||||
|
home,
|
||||||
|
away,
|
||||||
|
pickedId,
|
||||||
|
onSelect,
|
||||||
|
}) => (
|
||||||
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
|
<TeamChip
|
||||||
|
team={home}
|
||||||
|
picked={!!home && pickedId === home.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
vs
|
||||||
|
</Text>
|
||||||
|
<TeamChip
|
||||||
|
team={away}
|
||||||
|
picked={!!away && pickedId === away.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useServerMutation,
|
||||||
|
useServerSuspenseQuery,
|
||||||
|
} from "@/lib/tanstack-query/hooks";
|
||||||
|
import {
|
||||||
|
getMyPrediction,
|
||||||
|
getPlayerPrediction,
|
||||||
|
getPredictionsLeaderboard,
|
||||||
|
submitPrediction,
|
||||||
|
} from "./server";
|
||||||
|
|
||||||
|
export const predictionKeys = {
|
||||||
|
tournament: (tournamentId: string) => ['predictions', tournamentId] as const,
|
||||||
|
mine: (tournamentId: string) => ['predictions', tournamentId, 'mine'] as const,
|
||||||
|
leaderboard: (tournamentId: string) => ['predictions', tournamentId, 'leaderboard'] as const,
|
||||||
|
player: (tournamentId: string, playerId: string) => ['predictions', tournamentId, 'player', playerId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const predictionQueries = {
|
||||||
|
mine: (tournamentId: string) => ({
|
||||||
|
queryKey: predictionKeys.mine(tournamentId),
|
||||||
|
queryFn: () => getMyPrediction({ data: tournamentId }),
|
||||||
|
}),
|
||||||
|
leaderboard: (tournamentId: string) => ({
|
||||||
|
queryKey: predictionKeys.leaderboard(tournamentId),
|
||||||
|
queryFn: () => getPredictionsLeaderboard({ data: tournamentId }),
|
||||||
|
}),
|
||||||
|
player: (tournamentId: string, playerId: string) => ({
|
||||||
|
queryKey: predictionKeys.player(tournamentId, playerId),
|
||||||
|
queryFn: () => getPlayerPrediction({ data: { tournamentId, playerId } }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMyPrediction = (tournamentId: string) =>
|
||||||
|
useServerSuspenseQuery(predictionQueries.mine(tournamentId));
|
||||||
|
|
||||||
|
export const usePredictionsLeaderboard = (tournamentId: string) =>
|
||||||
|
useServerSuspenseQuery(predictionQueries.leaderboard(tournamentId));
|
||||||
|
|
||||||
|
export const usePlayerPrediction = (tournamentId: string, playerId: string) =>
|
||||||
|
useServerSuspenseQuery(predictionQueries.player(tournamentId, playerId));
|
||||||
|
|
||||||
|
export const useSubmitPrediction = (tournamentId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: submitPrediction,
|
||||||
|
successMessage: "Prediction saved!",
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: predictionKeys.tournament(tournamentId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import {
|
||||||
|
MyPrediction,
|
||||||
|
Prediction,
|
||||||
|
PredictionLeaderboardEntry,
|
||||||
|
PredictionsLeaderboard,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
computePredictionScore,
|
||||||
|
getPickableMatches,
|
||||||
|
isPredictionComplete,
|
||||||
|
isPredictionLocked,
|
||||||
|
isTournamentPredictable,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const getTournamentOrThrow = async (tournamentId: string): Promise<Tournament> => {
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
return tournament;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMyPrediction = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId, context }) =>
|
||||||
|
toServerResult(async (): Promise<MyPrediction> => {
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
const prediction = player
|
||||||
|
? await pbAdmin.getPrediction(tournamentId, player.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
prediction,
|
||||||
|
locked: isPredictionLocked(matches),
|
||||||
|
eligible: isTournamentPredictable(tournament),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitPredictionSchema = z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
picks: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitPrediction = createServerFn()
|
||||||
|
.validator(submitPredictionSchema)
|
||||||
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
||||||
|
toServerResult(async (): Promise<Prediction> => {
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
if (!player) {
|
||||||
|
throw new Error("Player not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
|
||||||
|
if (!isTournamentPredictable(tournament)) {
|
||||||
|
throw new Error("Predictions are not available for this tournament");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPredictionLocked(matches)) {
|
||||||
|
throw new Error("Predictions are locked — the tournament has started");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickableLids = new Set(
|
||||||
|
getPickableMatches(matches, picks).map((match) => String(match.lid))
|
||||||
|
);
|
||||||
|
const pickKeys = Object.keys(picks);
|
||||||
|
if (
|
||||||
|
pickKeys.length !== pickableLids.size ||
|
||||||
|
pickKeys.some((lid) => !pickableLids.has(lid))
|
||||||
|
) {
|
||||||
|
throw new Error("Prediction must include a pick for every match");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPredictionComplete(matches, picks)) {
|
||||||
|
throw new Error("Prediction contains invalid picks");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prediction = await pbAdmin.upsertPrediction(
|
||||||
|
tournamentId,
|
||||||
|
player.id,
|
||||||
|
picks
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Prediction submitted", {
|
||||||
|
tournamentId,
|
||||||
|
playerId: player.id,
|
||||||
|
pickCount: pickKeys.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return prediction;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPredictionsLeaderboard = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId }) =>
|
||||||
|
toServerResult(async (): Promise<PredictionsLeaderboard> => {
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const locked = isPredictionLocked(matches);
|
||||||
|
|
||||||
|
const predictions = await pbAdmin.getPredictionsForTournament(tournamentId);
|
||||||
|
const submitters = predictions.map((prediction) => prediction.player);
|
||||||
|
|
||||||
|
if (!locked) {
|
||||||
|
return { locked, count: predictions.length, entries: [], submitters };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: PredictionLeaderboardEntry[] = predictions.map(
|
||||||
|
(prediction) => {
|
||||||
|
const score = computePredictionScore(matches, prediction.picks);
|
||||||
|
const championPick = tournament.teams?.find(
|
||||||
|
(team) => team.id === score.predictedChampionId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
player: prediction.player,
|
||||||
|
points: score.points,
|
||||||
|
correct: score.correct,
|
||||||
|
total: score.total,
|
||||||
|
championPick,
|
||||||
|
championCorrect:
|
||||||
|
!!score.predictedChampionId &&
|
||||||
|
score.predictedChampionId === tournament.first_place?.id,
|
||||||
|
updated: prediction.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.points - a.points ||
|
||||||
|
Number(b.championCorrect) - Number(a.championCorrect) ||
|
||||||
|
a.updated.localeCompare(b.updated) ||
|
||||||
|
(a.player.first_name ?? "").localeCompare(b.player.first_name ?? "")
|
||||||
|
);
|
||||||
|
|
||||||
|
return { locked, count: entries.length, entries, submitters };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const playerPredictionSchema = z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
playerId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPlayerPrediction = createServerFn()
|
||||||
|
.validator(playerPredictionSchema)
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: { tournamentId, playerId }, context }) =>
|
||||||
|
toServerResult(async (): Promise<Prediction | null> => {
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
|
||||||
|
if (!isPredictionLocked(tournament.matches || [])) {
|
||||||
|
const me = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
if (me?.id !== playerId) {
|
||||||
|
throw new Error("Predictions are private until the tournament starts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pbAdmin.getPrediction(tournamentId, playerId);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
|
||||||
|
export type PicksMap = Record<string, string>;
|
||||||
|
|
||||||
|
export interface Prediction {
|
||||||
|
id: string;
|
||||||
|
tournament: string;
|
||||||
|
player: PlayerInfo;
|
||||||
|
picks: PicksMap;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionLeaderboardEntry {
|
||||||
|
player: PlayerInfo;
|
||||||
|
points: number;
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
championPick?: TeamInfo;
|
||||||
|
championCorrect: boolean;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionsLeaderboard {
|
||||||
|
locked: boolean;
|
||||||
|
count: number;
|
||||||
|
entries: PredictionLeaderboardEntry[];
|
||||||
|
submitters: PlayerInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyPrediction {
|
||||||
|
prediction: Prediction | null;
|
||||||
|
locked: boolean;
|
||||||
|
eligible: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import { PicksMap } from "./types";
|
||||||
|
|
||||||
|
const teamId = (team?: TeamInfo | Team | string): string | undefined =>
|
||||||
|
typeof team === "string" ? team : team?.id;
|
||||||
|
|
||||||
|
const asTeamInfo = (team?: TeamInfo | Team | string): TeamInfo | undefined =>
|
||||||
|
typeof team === "string" ? undefined : team;
|
||||||
|
|
||||||
|
export interface ResolvedMatch {
|
||||||
|
lid: number;
|
||||||
|
home?: TeamInfo;
|
||||||
|
homeId?: string;
|
||||||
|
away?: TeamInfo;
|
||||||
|
awayId?: string;
|
||||||
|
pickedWinner?: TeamInfo;
|
||||||
|
pickedWinnerId?: string;
|
||||||
|
pickedLoser?: TeamInfo;
|
||||||
|
pickedLoserId?: string;
|
||||||
|
resetNecessary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBracketMatch = (match: Match) => match.round !== -1 && !match.bye;
|
||||||
|
|
||||||
|
export const getPickableMatches = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): Match[] => {
|
||||||
|
const resolved = resolvePredictedBracket(matches, picks);
|
||||||
|
return matches
|
||||||
|
.filter(
|
||||||
|
(match) =>
|
||||||
|
isBracketMatch(match) &&
|
||||||
|
(!match.reset || resolved.get(match.lid)?.resetNecessary)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.lid - b.lid);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPredictionLocked = (matches: Match[]): boolean =>
|
||||||
|
matches.some(
|
||||||
|
(match) => match.status === "started" || match.status === "ended"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isTournamentPredictable = (tournament: Tournament): boolean => {
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
return (
|
||||||
|
!tournament.regional &&
|
||||||
|
matches.length > 0 &&
|
||||||
|
!matches.some((match) => match.round === -1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInternal = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap,
|
||||||
|
prune: boolean
|
||||||
|
): { resolved: Map<number, ResolvedMatch>; picks: PicksMap } => {
|
||||||
|
const resolved = new Map<number, ResolvedMatch>();
|
||||||
|
const nextPicks: PicksMap = { ...picks };
|
||||||
|
|
||||||
|
const bracketMatches = matches
|
||||||
|
.filter(isBracketMatch)
|
||||||
|
.sort((a, b) => a.lid - b.lid);
|
||||||
|
|
||||||
|
for (const match of bracketMatches) {
|
||||||
|
const entry: ResolvedMatch = { lid: match.lid };
|
||||||
|
|
||||||
|
if (match.home_from_lid === -1) {
|
||||||
|
entry.home = asTeamInfo(match.home);
|
||||||
|
entry.homeId = teamId(match.home);
|
||||||
|
} else {
|
||||||
|
const source = resolved.get(match.home_from_lid);
|
||||||
|
entry.home = match.home_from_loser
|
||||||
|
? source?.pickedLoser
|
||||||
|
: source?.pickedWinner;
|
||||||
|
entry.homeId = match.home_from_loser
|
||||||
|
? source?.pickedLoserId
|
||||||
|
: source?.pickedWinnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.away_from_lid === -1) {
|
||||||
|
entry.away = asTeamInfo(match.away);
|
||||||
|
entry.awayId = teamId(match.away);
|
||||||
|
} else {
|
||||||
|
const source = resolved.get(match.away_from_lid);
|
||||||
|
entry.away = match.away_from_loser
|
||||||
|
? source?.pickedLoser
|
||||||
|
: source?.pickedWinner;
|
||||||
|
entry.awayId = match.away_from_loser
|
||||||
|
? source?.pickedLoserId
|
||||||
|
: source?.pickedWinnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pickEligible = true;
|
||||||
|
if (match.reset) {
|
||||||
|
const grandFinal = resolved.get(match.home_from_lid);
|
||||||
|
entry.resetNecessary =
|
||||||
|
!!grandFinal?.pickedWinnerId &&
|
||||||
|
grandFinal.pickedWinnerId === grandFinal.awayId;
|
||||||
|
pickEligible = entry.resetNecessary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickId = nextPicks[String(match.lid)];
|
||||||
|
if (pickEligible && pickId && pickId === entry.homeId) {
|
||||||
|
entry.pickedWinner = entry.home;
|
||||||
|
entry.pickedWinnerId = entry.homeId;
|
||||||
|
entry.pickedLoser = entry.away;
|
||||||
|
entry.pickedLoserId = entry.awayId;
|
||||||
|
} else if (pickEligible && pickId && pickId === entry.awayId) {
|
||||||
|
entry.pickedWinner = entry.away;
|
||||||
|
entry.pickedWinnerId = entry.awayId;
|
||||||
|
entry.pickedLoser = entry.home;
|
||||||
|
entry.pickedLoserId = entry.homeId;
|
||||||
|
} else if (pickId && prune) {
|
||||||
|
delete nextPicks[String(match.lid)];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.set(match.lid, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolved, picks: nextPicks };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePredictedBracket = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): Map<number, ResolvedMatch> => resolveInternal(matches, picks, false).resolved;
|
||||||
|
|
||||||
|
export const setPick = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap,
|
||||||
|
lid: number,
|
||||||
|
pickedTeamId: string
|
||||||
|
): PicksMap =>
|
||||||
|
resolveInternal(
|
||||||
|
matches,
|
||||||
|
{ ...picks, [String(lid)]: pickedTeamId },
|
||||||
|
true
|
||||||
|
).picks;
|
||||||
|
|
||||||
|
export const isPredictionComplete = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): boolean => {
|
||||||
|
const resolved = resolvePredictedBracket(matches, picks);
|
||||||
|
return getPickableMatches(matches, picks).every(
|
||||||
|
(match) => resolved.get(match.lid)?.pickedWinnerId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMatchLabel = (matches: Match[], match: Match): string => {
|
||||||
|
if (match.reset) return "Bracket Reset";
|
||||||
|
|
||||||
|
const winners = matches.filter(
|
||||||
|
(m) => isBracketMatch(m) && !m.reset && !m.is_losers_bracket
|
||||||
|
);
|
||||||
|
const grandFinal = winners.reduce(
|
||||||
|
(highest: Match | undefined, current) =>
|
||||||
|
!highest || current.lid > highest.lid ? current : highest,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
if (!grandFinal) return `Match ${match.order}`;
|
||||||
|
|
||||||
|
const hasLosersBracket = matches.some(
|
||||||
|
(m) => isBracketMatch(m) && m.is_losers_bracket
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match.lid === grandFinal.lid) return "Final";
|
||||||
|
if (
|
||||||
|
hasLosersBracket &&
|
||||||
|
!match.is_losers_bracket &&
|
||||||
|
grandFinal.home_from_lid === match.lid
|
||||||
|
) {
|
||||||
|
return "Winners Bracket Final";
|
||||||
|
}
|
||||||
|
if (match.is_losers_bracket && grandFinal.away_from_lid === match.lid) {
|
||||||
|
return "Losers Bracket Final";
|
||||||
|
}
|
||||||
|
return `Match ${match.order}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PickResult = "correct" | "incorrect" | "pending";
|
||||||
|
|
||||||
|
export interface PredictionScore {
|
||||||
|
points: number;
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
perMatch: Map<number, PickResult>;
|
||||||
|
predictedChampionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMatchPoints = (match: Match): number =>
|
||||||
|
(match.is_losers_bracket ? 5 : 10) * 2 ** match.round;
|
||||||
|
|
||||||
|
export const computePredictionScore = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): PredictionScore => {
|
||||||
|
const pickable = getPickableMatches(matches, picks);
|
||||||
|
const perMatch = new Map<number, PickResult>();
|
||||||
|
|
||||||
|
let points = 0;
|
||||||
|
let correct = 0;
|
||||||
|
|
||||||
|
for (const match of pickable) {
|
||||||
|
const pickId = picks[String(match.lid)];
|
||||||
|
|
||||||
|
if (match.status !== "ended") {
|
||||||
|
perMatch.set(match.lid, "pending");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualWinnerId =
|
||||||
|
match.home_cups > match.away_cups ? teamId(match.home) : teamId(match.away);
|
||||||
|
|
||||||
|
if (pickId && actualWinnerId && pickId === actualWinnerId) {
|
||||||
|
perMatch.set(match.lid, "correct");
|
||||||
|
points += getMatchPoints(match);
|
||||||
|
correct += 1;
|
||||||
|
} else {
|
||||||
|
perMatch.set(match.lid, "incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grandFinal = pickable
|
||||||
|
.filter((match) => !match.is_losers_bracket)
|
||||||
|
.at(-1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
correct,
|
||||||
|
total: pickable.length,
|
||||||
|
perMatch,
|
||||||
|
predictedChampionId: grandFinal
|
||||||
|
? picks[String(grandFinal.lid)]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -37,7 +37,7 @@ const EmojiPicker = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
position="bottom"
|
position="top-end"
|
||||||
withArrow
|
withArrow
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
opened={opened}
|
opened={opened}
|
||||||
@@ -45,6 +45,7 @@ const EmojiPicker = ({
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
|
withinPortal
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { teamInputSchema, teamUpdateSchema } from "./types";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Match } from "../matches/types";
|
import { Match } from "../matches/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
|
|
||||||
export const listTeamInfos = createServerFn()
|
export const listTeamInfos = createServerFn()
|
||||||
@@ -42,7 +43,9 @@ export const createTeam = createServerFn()
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
||||||
return pbAdmin.createTeam(data);
|
const team = await pbAdmin.createTeam(data);
|
||||||
|
emitServerEvent({ type: "team", teamId: team?.id });
|
||||||
|
return team;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +71,9 @@ export const updateTeam = createServerFn()
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
||||||
return pbAdmin.updateTeam(id, updates);
|
const updated = await pbAdmin.updateTeam(id, updates);
|
||||||
|
emitServerEvent({ type: "team", teamId: id });
|
||||||
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.indicators.indicators {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--mantine-spacing-xs);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.indicator {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--mantine-color-default-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.indicator[data-active] {
|
||||||
|
width: 16px;
|
||||||
|
background-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.indicator.indicator {
|
||||||
|
transition: background-color 150ms ease-out, width 150ms ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack px="sm" align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<GlitchAvatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -27,8 +26,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={250}
|
size={250}
|
||||||
px="xs"
|
|
||||||
withBorder={false}
|
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</GlitchAvatar>
|
</GlitchAvatar>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { 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, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
import { Box, Divider, Stack, Text, Card, Center, Group, Indicator } from "@mantine/core";
|
||||||
import { Carousel } from "@mantine/carousel";
|
import { Carousel } from "@mantine/carousel";
|
||||||
|
import carouselClasses from "./carousel.module.css";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
|
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||||
|
import { predictionQueries } from "@/features/predictions/queries";
|
||||||
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
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";
|
||||||
@@ -41,16 +46,49 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||||
}, [tournament.matches]);
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const isPredictable = useMemo(
|
||||||
|
() => isTournamentPredictable(tournament),
|
||||||
|
[tournament]
|
||||||
|
);
|
||||||
|
|
||||||
|
const predictionsLocked = useMemo(
|
||||||
|
() => isPredictionLocked(tournament.matches || []),
|
||||||
|
[tournament.matches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: myPrediction } = useServerQuery({
|
||||||
|
...predictionQueries.mine(tournament.id),
|
||||||
|
options: { enabled: isPredictable && !predictionsLocked },
|
||||||
|
});
|
||||||
|
const hasSubmitted = !!myPrediction?.prediction;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
|
|
||||||
{startedMatches.length > 0 ? (
|
{startedMatches.length > 0 ? (
|
||||||
<Box>
|
<Box>
|
||||||
|
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap">
|
||||||
|
<Indicator
|
||||||
|
size={8}
|
||||||
|
color="red"
|
||||||
|
processing
|
||||||
|
position="middle-start"
|
||||||
|
offset={0}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em">
|
||||||
|
Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
<Carousel
|
<Carousel
|
||||||
slideSize="95%"
|
slideSize="90%"
|
||||||
slideGap="xs"
|
slideGap="xs"
|
||||||
withControls={false}
|
withControls={false}
|
||||||
|
withIndicators={startedMatches.length > 1}
|
||||||
|
classNames={{
|
||||||
|
indicators: carouselClasses.indicators,
|
||||||
|
indicator: carouselClasses.indicator,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{startedMatches.map((match, index) => (
|
{startedMatches.map((match, index) => (
|
||||||
<Carousel.Slide key={match.id}>
|
<Carousel.Slide key={match.id}>
|
||||||
@@ -99,6 +137,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
/>
|
/>
|
||||||
|
{isPredictable && !predictionsLocked && (
|
||||||
|
<ListLink
|
||||||
|
label={hasSubmitted ? `Edit Your Prediction` : `Make Your Prediction`}
|
||||||
|
to={`/tournaments/${tournament.id}/predictions/make`}
|
||||||
|
Icon={WizardOrbIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||||
|
<ListLink
|
||||||
|
label={`View Predictions`}
|
||||||
|
to={`/tournaments/${tournament.id}/predictions`}
|
||||||
|
Icon={WizardOrbIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
<RulesListButton tournamentId={tournament.id} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ const StartedTournamentSkeleton = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Header skeleton */}
|
{/* Header skeleton */}
|
||||||
<Stack px="md">
|
<Stack px="md" align="center" gap="xs">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Skeleton height={268} width={268} radius="lg" />
|
||||||
<Box style={{ flex: 1 }}>
|
<Skeleton height={16} width="55%" />
|
||||||
<Skeleton height={32} width="60%" mb="xs" />
|
|
||||||
<Skeleton height={16} width="40%" />
|
|
||||||
</Box>
|
|
||||||
<Skeleton height={60} width={60} radius="md" />
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Match carousel skeleton */}
|
{/* Match carousel skeleton */}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
import { isTournamentPredictable } from "@/features/predictions/utils";
|
||||||
import { Podium } from "./podium";
|
import { Podium } from "./podium";
|
||||||
|
|
||||||
interface TournamentStatsProps {
|
interface TournamentStatsProps {
|
||||||
@@ -185,6 +187,13 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
/>
|
/>
|
||||||
|
{isTournamentPredictable(tournament) && (
|
||||||
|
<ListLink
|
||||||
|
label={`View Predictions`}
|
||||||
|
to={`/tournaments/${tournament.id}/predictions`}
|
||||||
|
Icon={WizardOrbIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renderTeamStatsTable()}
|
{renderTeamStatsTable()}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack align="center" gap={16}>
|
<Stack align="center" gap={16}>
|
||||||
<GlitchAvatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -32,8 +31,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={300}
|
size={300}
|
||||||
px="xs"
|
|
||||||
withBorder={false}
|
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</GlitchAvatar>
|
</GlitchAvatar>
|
||||||
|
|||||||
@@ -57,12 +57,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
|
||||||
p="lg"
|
p="lg"
|
||||||
style={{
|
style={{
|
||||||
|
borderRadius:
|
||||||
|
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)",
|
||||||
borderTop: "3px solid var(--mantine-primary-color-filled)",
|
borderTop: "3px solid var(--mantine-primary-color-filled)",
|
||||||
backgroundImage:
|
|
||||||
"linear-gradient(to bottom, var(--mantine-primary-color-light), transparent 110px)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const UpcomingTournamentSkeleton = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Flex px="md" justify="center" w="100%">
|
<Flex px="md" justify="center" w="100%">
|
||||||
<Skeleton height={200} width={240} radius="md" />
|
<Skeleton height={318} width={318} radius="lg" />
|
||||||
</Flex>
|
</Flex>
|
||||||
<Stack align="center" gap={2}>
|
<Stack align="center" gap={2}>
|
||||||
<Skeleton height={16} w="30%" mb="md" />
|
<Skeleton height={16} w="30%" mb="md" />
|
||||||
@@ -12,7 +12,14 @@ const UpcomingTournamentSkeleton = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack px="md">
|
<Stack px="md">
|
||||||
<Card withBorder radius="lg" p="lg">
|
<Card
|
||||||
|
withBorder
|
||||||
|
p="lg"
|
||||||
|
style={{
|
||||||
|
borderRadius:
|
||||||
|
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Skeleton height={14} width="80%" mb={16} />
|
<Skeleton height={14} width="80%" mb={16} />
|
||||||
<Group mb="sm" gap="xs" align="center">
|
<Group mb="sm" gap="xs" align="center">
|
||||||
<Skeleton height={32} width={16} />
|
<Skeleton height={32} width={16} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { emitServerEvent } from "@/lib/events/emitter";
|
||||||
import brackets from "@/features/bracket/utils";
|
import brackets from "@/features/bracket/utils";
|
||||||
import { MatchInput } from "@/features/matches/types";
|
import { MatchInput } from "@/features/matches/types";
|
||||||
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||||
@@ -19,8 +20,12 @@ export const listTournaments = createServerFn()
|
|||||||
export const createTournament = createServerFn()
|
export const createTournament = createServerFn()
|
||||||
.validator(tournamentInputSchema)
|
.validator(tournamentInputSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.createTournament(data))
|
toServerResult(async () => {
|
||||||
|
const tournament = await pbAdmin.createTournament(data);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: tournament.id });
|
||||||
|
return tournament;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateTournament = createServerFn()
|
export const updateTournament = createServerFn()
|
||||||
@@ -29,8 +34,12 @@ export const updateTournament = createServerFn()
|
|||||||
updates: tournamentInputSchema.partial()
|
updates: tournamentInputSchema.partial()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
toServerResult(async () => {
|
||||||
|
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.id });
|
||||||
|
return tournament;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTournament = createServerFn()
|
export const getTournament = createServerFn()
|
||||||
@@ -76,6 +85,7 @@ export const enrollTeam = createServerFn()
|
|||||||
|
|
||||||
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
||||||
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
return tournament;
|
return tournament;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -86,8 +96,12 @@ export const unenrollTeam = createServerFn()
|
|||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
toServerResult(async () => {
|
||||||
|
const result = await pbAdmin.unenrollTeam(tournamentId, teamId);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
|
return result;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getUnenrolledTeams = createServerFn()
|
export const getUnenrolledTeams = createServerFn()
|
||||||
@@ -115,6 +129,7 @@ export const enrollFreeAgent = createServerFn()
|
|||||||
|
|
||||||
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
|
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
|
||||||
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
|
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,6 +144,7 @@ export const unenrollFreeAgent = createServerFn()
|
|||||||
|
|
||||||
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
|
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
|
||||||
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,6 +398,9 @@ export const confirmTeamAssignments = createServerFn()
|
|||||||
newCount: createdTeams.length - reusedCount
|
newCount: createdTeams.length - reusedCount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "team" });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
|
|
||||||
return { teams: createdTeams };
|
return { teams: createdTeams };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -702,6 +721,8 @@ export const generateKnockoutBracket = createServerFn()
|
|||||||
qualifiedTeamCount: qualifiedTeams.length
|
qualifiedTeamCount: qualifiedTeams.length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
matchCount: createdMatches.length,
|
matchCount: createdMatches.length,
|
||||||
@@ -720,6 +741,7 @@ export const adminEnrollPlayer = createServerFn()
|
|||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
||||||
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -733,6 +755,7 @@ export const adminUnenrollPlayer = createServerFn()
|
|||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
||||||
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -881,6 +904,8 @@ export const generateGroupStage = createServerFn()
|
|||||||
totalMatchCount: createdMatches.length
|
totalMatchCount: createdMatches.length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
groups: createdGroups,
|
groups: createdGroups,
|
||||||
|
|||||||
+118
-57
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
|
import { reactionKeys } from "@/features/reactions/queries";
|
||||||
|
import { predictionKeys } from "@/features/predictions/queries";
|
||||||
|
|
||||||
const logger = new Logger('ServerEvents');
|
const logger = new Logger('ServerEvents');
|
||||||
|
|
||||||
@@ -12,11 +13,14 @@ type SSEEvent = {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void;
|
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>) => void;
|
||||||
|
|
||||||
const INVALIDATE_DEBOUNCE_MS = 1500;
|
const INVALIDATE_DEBOUNCE_MS = 1000;
|
||||||
|
const INVALIDATE_JITTER_MS = 1500;
|
||||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const WATCHDOG_MS = 45_000;
|
||||||
|
|
||||||
function debouncedInvalidate(
|
function debouncedInvalidate(
|
||||||
queryClient: ReturnType<typeof useQueryClient>,
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
filters: { queryKey: readonly unknown[] }
|
filters: { queryKey: readonly unknown[] }
|
||||||
@@ -25,10 +29,11 @@ function debouncedInvalidate(
|
|||||||
const existing = invalidateTimers.get(key);
|
const existing = invalidateTimers.get(key);
|
||||||
if (existing) clearTimeout(existing);
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
|
const delay = INVALIDATE_DEBOUNCE_MS + Math.random() * INVALIDATE_JITTER_MS;
|
||||||
invalidateTimers.set(key, setTimeout(() => {
|
invalidateTimers.set(key, setTimeout(() => {
|
||||||
invalidateTimers.delete(key);
|
invalidateTimers.delete(key);
|
||||||
queryClient.invalidateQueries(filters);
|
queryClient.invalidateQueries(filters);
|
||||||
}, INVALIDATE_DEBOUNCE_MS));
|
}, delay));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingInvalidations() {
|
function clearPendingInvalidations() {
|
||||||
@@ -39,53 +44,108 @@ function clearPendingInvalidations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventHandlers: Record<string, EventHandler> = {
|
const eventHandlers: Record<string, EventHandler> = {
|
||||||
"connected": () => {
|
|
||||||
logger.info("New Connection");
|
|
||||||
},
|
|
||||||
"ping": () => {},
|
"ping": () => {},
|
||||||
"heartbeat": () => {},
|
"test": (event) => {
|
||||||
|
logger.info("Test event", event);
|
||||||
|
},
|
||||||
|
"tournament": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['tournaments'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'unenrolled'] });
|
||||||
|
},
|
||||||
"match": (event, queryClient) => {
|
"match": (event, queryClient) => {
|
||||||
debouncedInvalidate(queryClient, tournamentQueries.details(event.tournamentId))
|
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.details(event.tournamentId) });
|
||||||
debouncedInvalidate(queryClient, tournamentQueries.current())
|
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.current });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: predictionKeys.tournament(event.tournamentId) });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'stats'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'matches'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'activity'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['teams', 'stats'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['teams', 'matches'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['matches'] });
|
||||||
},
|
},
|
||||||
"reaction": (event, queryClient) => {
|
"reaction": (event, queryClient) => {
|
||||||
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
|
|
||||||
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
|
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
|
||||||
}
|
},
|
||||||
|
"team": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['teams'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['tournaments'] });
|
||||||
|
},
|
||||||
|
"player": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players'] });
|
||||||
|
},
|
||||||
|
"badge": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['badges'] });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useServerEvents() {
|
export function useServerEvents() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const retryCountRef = useRef(0);
|
|
||||||
const shouldConnectRef = useRef(true);
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
shouldConnectRef.current = true;
|
let disposed = false;
|
||||||
retryCountRef.current = 0;
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let watchdogTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let retryCount = 0;
|
||||||
|
let hasConnectedOnce = false;
|
||||||
|
|
||||||
const connectEventSource = () => {
|
const disconnect = () => {
|
||||||
if (!shouldConnectRef.current) return;
|
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnect = (delay: number) => {
|
||||||
|
if (disposed) return;
|
||||||
|
disconnect();
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
const eventSource = new EventSource(`/api/events/$`);
|
const armWatchdog = () => {
|
||||||
|
if (watchdogTimer) clearTimeout(watchdogTimer);
|
||||||
|
watchdogTimer = setTimeout(() => {
|
||||||
|
logger.warn(`SSE watchdog: no messages in ${WATCHDOG_MS}ms, reconnecting`);
|
||||||
|
retryCount = 0;
|
||||||
|
reconnect(0);
|
||||||
|
}, WATCHDOG_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (disposed || eventSource) return;
|
||||||
|
|
||||||
|
eventSource = new EventSource(`/api/events/$`);
|
||||||
|
armWatchdog();
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
retryCountRef.current = 0;
|
retryCount = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
armWatchdog();
|
||||||
try {
|
try {
|
||||||
const data: SSEEvent = JSON.parse(event.data);
|
const data: SSEEvent = JSON.parse(event.data);
|
||||||
logger.info("Event received", data);
|
if (data.type !== "ping") {
|
||||||
|
logger.info("Event received", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "connected") {
|
||||||
|
if (hasConnectedOnce) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!disposed) queryClient.invalidateQueries();
|
||||||
|
}, Math.random() * 2000);
|
||||||
|
}
|
||||||
|
hasConnectedOnce = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handler = eventHandlers[data.type];
|
const handler = eventHandlers[data.type];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
handler(data, queryClient, user?.id);
|
handler(data, queryClient);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Unhandled SSE event type: ${data.type}`);
|
logger.warn(`Unhandled SSE event type: ${data.type}`);
|
||||||
}
|
}
|
||||||
@@ -94,50 +154,51 @@ export function useServerEvents() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = async (error) => {
|
||||||
|
if (disposed) return;
|
||||||
logger.error("SSE connection error", error);
|
logger.error("SSE connection error", error);
|
||||||
eventSource.close();
|
disconnect();
|
||||||
|
|
||||||
if (shouldConnectRef.current && retryCountRef.current < 10) {
|
retryCount += 1;
|
||||||
retryCountRef.current += 1;
|
const delay = Math.min(1000 * Math.pow(1.5, retryCount - 1), 15000);
|
||||||
const delay = Math.min(
|
logger.info(`SSE reconnection attempt ${retryCount} in ${Math.round(delay)}ms`);
|
||||||
1000 * Math.pow(1.5, retryCountRef.current - 1),
|
|
||||||
15000
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
try {
|
||||||
`SSE reconnection attempt ${retryCountRef.current}/10 in ${delay}ms`
|
const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
||||||
);
|
await attemptRefreshingSession();
|
||||||
|
} catch {
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
if (shouldConnectRef.current) {
|
|
||||||
connectEventSource();
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
} else if (retryCountRef.current >= 10) {
|
|
||||||
logger.error("SSE max reconnection attempts reached");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return eventSource;
|
reconnect(delay);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventSource = connectEventSource();
|
const wake = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = 0;
|
||||||
|
reconnect(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') wake();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', wake);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
logger.info("Closing SSE connection");
|
logger.info("Closing SSE connection");
|
||||||
shouldConnectRef.current = false;
|
disposed = true;
|
||||||
|
|
||||||
clearPendingInvalidations();
|
clearPendingInvalidations();
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
window.removeEventListener('online', wake);
|
||||||
clearTimeout(timeoutRef.current);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventSource) {
|
disconnect();
|
||||||
eventSource.close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [user?.id]);
|
}, [user?.id, queryClient]);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-23
@@ -1,38 +1,81 @@
|
|||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
export const serverEvents = new EventEmitter();
|
|
||||||
|
|
||||||
serverEvents.setMaxListeners(50);
|
|
||||||
|
|
||||||
// Debug logging for listener count
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
setInterval(() => {
|
|
||||||
const listenerCounts = {
|
|
||||||
test: serverEvents.listenerCount('test'),
|
|
||||||
match: serverEvents.listenerCount('match'),
|
|
||||||
reaction: serverEvents.listenerCount('reaction'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (listenerCounts.test > 0 || listenerCounts.match > 0 || listenerCounts.reaction > 0) {
|
|
||||||
console.log('ServerEvents listener count:', listenerCounts);
|
|
||||||
}
|
|
||||||
}, 30000); // Log every 30 seconds in development
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TestEvent = {
|
export type TestEvent = {
|
||||||
type: "test";
|
type: "test";
|
||||||
playerId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MatchEvent = {
|
export type MatchEvent = {
|
||||||
type: "match";
|
type: "match";
|
||||||
matchId: string;
|
matchId: string;
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ReactionEvent = {
|
export type ReactionEvent = {
|
||||||
type: "reaction";
|
type: "reaction";
|
||||||
matchId: string;
|
matchId: string;
|
||||||
}
|
reactions: Array<{
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
players: Array<{ id?: string; first_name?: string; last_name?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerEvent = TestEvent | MatchEvent | ReactionEvent;
|
export type TournamentEvent = {
|
||||||
|
type: "tournament";
|
||||||
|
tournamentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamEvent = {
|
||||||
|
type: "team";
|
||||||
|
teamId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerEvent = {
|
||||||
|
type: "player";
|
||||||
|
playerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BadgeEvent = {
|
||||||
|
type: "badge";
|
||||||
|
playerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerEvent =
|
||||||
|
| TestEvent
|
||||||
|
| MatchEvent
|
||||||
|
| ReactionEvent
|
||||||
|
| TournamentEvent
|
||||||
|
| TeamEvent
|
||||||
|
| PlayerEvent
|
||||||
|
| BadgeEvent;
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"test",
|
||||||
|
"match",
|
||||||
|
"reaction",
|
||||||
|
"tournament",
|
||||||
|
"team",
|
||||||
|
"player",
|
||||||
|
"badge",
|
||||||
|
] as const satisfies readonly ServerEvent["type"][];
|
||||||
|
|
||||||
|
export const serverEvents = new EventEmitter();
|
||||||
|
|
||||||
|
serverEvents.setMaxListeners(200);
|
||||||
|
|
||||||
|
export const emitServerEvent = (event: ServerEvent) => {
|
||||||
|
serverEvents.emit(event.type, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
setInterval(() => {
|
||||||
|
const listenerCounts = Object.fromEntries(
|
||||||
|
EVENT_TYPES.map((type) => [type, serverEvents.listenerCount(type)])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.values(listenerCounts).some((count) => count > 0)) {
|
||||||
|
console.log('ServerEvents listener count:', listenerCounts);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { createGroupsService } from "./services/groups";
|
import { createGroupsService } from "./services/groups";
|
||||||
|
import { createPredictionsService } from "./services/predictions";
|
||||||
|
|
||||||
class PocketBaseAdminClient {
|
class PocketBaseAdminClient {
|
||||||
private pb: PocketBase;
|
private pb: PocketBase;
|
||||||
@@ -48,6 +49,7 @@ class PocketBaseAdminClient {
|
|||||||
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));
|
Object.assign(this, createGroupsService(this.pb));
|
||||||
|
Object.assign(this, createPredictionsService(this.pb));
|
||||||
|
|
||||||
this.authPromise = this.authenticate();
|
this.authPromise = this.authenticate();
|
||||||
this.authPromise.then(() => {
|
this.authPromise.then(() => {
|
||||||
@@ -126,7 +128,8 @@ interface AdminClient
|
|||||||
ReturnType<typeof createReactionsService>,
|
ReturnType<typeof createReactionsService>,
|
||||||
ReturnType<typeof createActivitiesService>,
|
ReturnType<typeof createActivitiesService>,
|
||||||
ReturnType<typeof createBadgesService>,
|
ReturnType<typeof createBadgesService>,
|
||||||
ReturnType<typeof createGroupsService> {
|
ReturnType<typeof createGroupsService>,
|
||||||
|
ReturnType<typeof createPredictionsService> {
|
||||||
authPromise: Promise<void>;
|
authPromise: Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { PicksMap, Prediction } from "@/features/predictions/types";
|
||||||
|
import { transformPrediction } from "../util/transform-types";
|
||||||
|
|
||||||
|
export function createPredictionsService(pb: PocketBase) {
|
||||||
|
return {
|
||||||
|
async getPrediction(
|
||||||
|
tournamentId: string,
|
||||||
|
playerId: string
|
||||||
|
): Promise<Prediction | null> {
|
||||||
|
try {
|
||||||
|
const record = await pb
|
||||||
|
.collection("predictions")
|
||||||
|
.getFirstListItem(
|
||||||
|
`tournament="${tournamentId}" && player="${playerId}"`,
|
||||||
|
{ expand: "player" }
|
||||||
|
);
|
||||||
|
return transformPrediction(record);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPredictionsForTournament(
|
||||||
|
tournamentId: string
|
||||||
|
): Promise<Prediction[]> {
|
||||||
|
const records = await pb.collection("predictions").getFullList({
|
||||||
|
filter: `tournament="${tournamentId}"`,
|
||||||
|
expand: "player",
|
||||||
|
sort: "updated",
|
||||||
|
});
|
||||||
|
return records.map(transformPrediction);
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertPrediction(
|
||||||
|
tournamentId: string,
|
||||||
|
playerId: string,
|
||||||
|
picks: PicksMap
|
||||||
|
): Promise<Prediction> {
|
||||||
|
const existing = await this.getPrediction(tournamentId, playerId);
|
||||||
|
|
||||||
|
const record = existing
|
||||||
|
? await pb
|
||||||
|
.collection("predictions")
|
||||||
|
.update(existing.id, { picks }, { expand: "player" })
|
||||||
|
: await pb.collection("predictions").create(
|
||||||
|
{
|
||||||
|
tournament: tournamentId,
|
||||||
|
player: playerId,
|
||||||
|
picks,
|
||||||
|
},
|
||||||
|
{ expand: "player" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return transformPrediction(record);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePredictionsForTournament(tournamentId: string): Promise<void> {
|
||||||
|
const records = await pb.collection("predictions").getFullList({
|
||||||
|
filter: `tournament="${tournamentId}"`,
|
||||||
|
fields: "id",
|
||||||
|
});
|
||||||
|
for (const record of records) {
|
||||||
|
await pb.collection("predictions").delete(record.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Player, PlayerInfo } from "@/features/players/types";
|
|||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||||
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||||
|
import { Prediction } from "@/features/predictions/types";
|
||||||
import { Activity } from "../services/activities";
|
import { Activity } from "../services/activities";
|
||||||
|
|
||||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||||
@@ -295,6 +296,17 @@ export function transformReaction(record: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformPrediction(record: any): Prediction {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
tournament: record.tournament,
|
||||||
|
player: transformPlayerInfo(record.expand?.player ?? { id: record.player }),
|
||||||
|
picks: record.picks || {},
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function transformBadgeInfo(record: any): BadgeInfo {
|
export function transformBadgeInfo(record: any): BadgeInfo {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { superTokensFunctionMiddleware } from "./supertokens";
|
import { superTokensFunctionMiddleware } from "./supertokens";
|
||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
export const testEvent = createServerFn()
|
export const testEvent = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
serverEvents.emit("test", {
|
emitServerEvent({
|
||||||
type: "test",
|
type: "test",
|
||||||
userId: context.userAuthId,
|
userId: context.userAuthId,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user