From 579071ff5dec5f250cc4231de225d582287ae530 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:30:00 +0200 Subject: [PATCH 01/31] Update update-project-status.yml --- .github/workflows/update-project-status.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-project-status.yml b/.github/workflows/update-project-status.yml index 664bb89a..79c7492b 100644 --- a/.github/workflows/update-project-status.yml +++ b/.github/workflows/update-project-status.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, closed] branches: - - development + - develop - master jobs: From 5360b139e8631bdffc7f7d1432a0b66de3596fab Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:34:40 +0200 Subject: [PATCH 02/31] Update updateProjectStatus.js --- scripts/updateProjectStatus.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/updateProjectStatus.js b/scripts/updateProjectStatus.js index 077fab9b..705bd12d 100644 --- a/scripts/updateProjectStatus.js +++ b/scripts/updateProjectStatus.js @@ -59,9 +59,13 @@ const repoData = await graphql(` title fields(first: 20) { nodes { - id - name + ... on ProjectV2Field { + id + name + } ... on ProjectV2SingleSelectField { + id + name options { id name From 8f29c8deea5515e71564aa864b70f9fb3bfd89f3 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:35:16 +0200 Subject: [PATCH 03/31] fix: Toast text does not wrap (#87) --- src/components/ToastProvider/ToastProvider.module.scss | 2 ++ src/services/auth/useRequestPasswordReset.ts | 2 +- src/services/auth/useResetPassword.ts | 2 +- src/services/auth/useSignIn.ts | 2 +- src/services/auth/useSignOut.ts | 2 +- src/services/auth/useSignUp.ts | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/ToastProvider/ToastProvider.module.scss b/src/components/ToastProvider/ToastProvider.module.scss index 651e6aa3..515e06a8 100644 --- a/src/components/ToastProvider/ToastProvider.module.scss +++ b/src/components/ToastProvider/ToastProvider.module.scss @@ -100,6 +100,8 @@ .Content { @include flex.column($gap: 0.25rem); + + min-width: 0; } .Title { diff --git a/src/services/auth/useRequestPasswordReset.ts b/src/services/auth/useRequestPasswordReset.ts index 002895f5..9ee98e19 100644 --- a/src/services/auth/useRequestPasswordReset.ts +++ b/src/services/auth/useRequestPasswordReset.ts @@ -22,7 +22,7 @@ export const useRequestPasswordReset = () => { flow: 'reset', }).catch((error) => { console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }).finally(() => { setLoading(false); toast.success('Code sent!'); diff --git a/src/services/auth/useResetPassword.ts b/src/services/auth/useResetPassword.ts index e3e73745..82554ed0 100644 --- a/src/services/auth/useResetPassword.ts +++ b/src/services/auth/useResetPassword.ts @@ -49,7 +49,7 @@ export const useResetPassword = () => { }).catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, diff --git a/src/services/auth/useSignIn.ts b/src/services/auth/useSignIn.ts index 626929ea..becc3372 100644 --- a/src/services/auth/useSignIn.ts +++ b/src/services/auth/useSignIn.ts @@ -48,7 +48,7 @@ export const useSignIn = () => { }).catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, diff --git a/src/services/auth/useSignOut.ts b/src/services/auth/useSignOut.ts index 9615a623..f2142e93 100644 --- a/src/services/auth/useSignOut.ts +++ b/src/services/auth/useSignOut.ts @@ -40,7 +40,7 @@ export const useSignOut = () => { await signOut().catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, diff --git a/src/services/auth/useSignUp.ts b/src/services/auth/useSignUp.ts index 8fe1f94f..86050795 100644 --- a/src/services/auth/useSignUp.ts +++ b/src/services/auth/useSignUp.ts @@ -58,7 +58,7 @@ export const useSignUp = () => { }).catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, From 758b72a9a8ff2bcfbaab13bd58a134c1d302e229 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:44:28 +0200 Subject: [PATCH 04/31] fix: Sanitize sign in/sign up inputs (#91) #86 --- src/components/generic/Form/Form.tsx | 4 +++- .../ForgotPasswordForm.schema.ts | 2 +- .../ResetPasswordForm.schema.ts | 8 +++---- .../SignInForm/SignInForm.schema.ts | 4 ++-- .../components/SignInForm/SignInForm.tsx | 2 +- .../SignUpForm/SignUpForm.schema.ts | 21 ++++++++++++------- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/generic/Form/Form.tsx b/src/components/generic/Form/Form.tsx index 24b8f53e..f2f09739 100644 --- a/src/components/generic/Form/Form.tsx +++ b/src/components/generic/Form/Form.tsx @@ -20,6 +20,7 @@ export interface FormProps { children: ReactNode; onSubmit: SubmitHandler; className?: string; + useBlocker?: boolean; } export const Form = ({ @@ -27,12 +28,13 @@ export const Form = ({ form, children, className, + useBlocker: block = true, onSubmit, }: FormProps) => { const { isDirty } = form.formState; const blockNavigation = useRef(true); const navigation = useNavigation(); - const blocker = useBlocker(() => isDirty && blockNavigation.current); + const blocker = useBlocker(() => block && isDirty && blockNavigation.current); const handleSubmit = async (e: BaseSyntheticEvent): Promise => { e.stopPropagation(); blockNavigation.current = false; diff --git a/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts b/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts index dae5b2cf..011f06b6 100644 --- a/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts +++ b/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const forgotPasswordFormSchema = z.object({ - email: z.string().min(1, 'Please enter your email.'), + email: z.string().email().transform((val) => val.trim().toLowerCase()), }); export type ForgotPasswordFormData = z.infer; diff --git a/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts b/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts index 0c35e8f3..241b1765 100644 --- a/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts +++ b/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; export const resetPasswordFormSchema = z.object({ - code: z.string().min(1).max(8), - email: z.string().email(), - newPassword: z.string().min(8, 'Password must be at least 8 characters.'), - newPasswordRepeat: z.string(), + code: z.string().min(1).max(8).transform((val) => val.trim()), + email: z.string().email().transform((val) => val.trim().toLowerCase()), + newPassword: z.string().min(8, 'Password must be at least 8 characters.').transform((val) => val.trim()), + newPasswordRepeat: z.string().transform((val) => val.trim()), }).superRefine((values, ctx) => { if (values.newPassword !== values.newPasswordRepeat) { ctx.addIssue({ diff --git a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts index cd6143f4..a80f008c 100644 --- a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts +++ b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; export const signInFormSchema = z.object({ - email: z.string().min(1, 'Please enter your email.'), - password: z.string().min(1, 'Please enter your password.'), + email: z.string().min(1, 'Please enter your email.').transform((val) => val.trim().toLowerCase()), + password: z.string().min(1, 'Please enter your password.').transform((val) => val.trim()), }); export type SignInFormData = z.infer; diff --git a/src/pages/AuthPage/components/SignInForm/SignInForm.tsx b/src/pages/AuthPage/components/SignInForm/SignInForm.tsx index beb46698..bb8bfc91 100644 --- a/src/pages/AuthPage/components/SignInForm/SignInForm.tsx +++ b/src/pages/AuthPage/components/SignInForm/SignInForm.tsx @@ -30,7 +30,7 @@ export const SignInForm = (): JSX.Element => { }; return ( -
+

Welcome Back

Sign in to your account

diff --git a/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts b/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts index e457024a..8c1fb5c2 100644 --- a/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts +++ b/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts @@ -1,12 +1,19 @@ import { z } from 'zod'; export const signUpFormSchema = z.object({ - email: z.string().email('Please enter a valid email.'), - password: z.string().min(8, 'Password must be at least 8 characters.'), - passwordRepeat: z.string(), - username: z.string().min(3, 'Must be at least 3 characters.').max(24, 'Cannot be longer than 24 characters.'), - givenName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.'), - familyName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.'), + email: z.string().email('Please enter a valid email.').transform((val) => val.trim().toLowerCase()), + password: z.string().min(8, 'Password must be at least 8 characters.').transform((val) => val.trim()), + passwordRepeat: z.string().transform((val) => val.trim()), + username: z.string() + .min(3, 'Must be at least 3 characters.') + .max(24, 'Cannot be longer than 24 characters.') + .regex( + /^(?!.*[._]{2})(?![._])[a-zA-Z0-9._]+(? val.trim().toLowerCase()), + givenName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.').transform((val) => val.trim()), + familyName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.').transform((val) => val.trim()), }).superRefine((values, ctx) => { if (values.password !== values.passwordRepeat) { ctx.addIssue({ @@ -19,7 +26,7 @@ export const signUpFormSchema = z.object({ export type SignUpFormData = z.infer; -export const defaultValues ={ +export const defaultValues = { email: '', password: '', givenName: '', From 38a7ab9ecd5de8df3d3eda09fad819640cba2a48 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:44:49 +0200 Subject: [PATCH 05/31] feat: #32 Auto generate avatars & refactor users (#90) --- convex/_generated/api.d.ts | 48 ++++++++++--------- .../_helpers/deepenMatchResultComment.ts | 9 ++-- .../_helpers/deepenMatchResultLike.ts | 9 ++-- .../_helpers/deepenMatchResult.ts | 10 ++-- .../_helpers/deepenTournamentCompetitor.ts | 33 +++++-------- convex/_model/users/_helpers/checkUserAuth.ts | 28 +++++++++++ .../checkUserTournamentRelationship.ts | 30 ++++++++++++ .../_model/users/_helpers/getShallowUser.ts | 28 +++++++++++ .../users/_helpers/redactUser.ts} | 46 +++++++++++++----- .../users/actions/setUserDefaultAvatar.ts | 48 +++++++++++++++++++ .../index.ts => _model/users/fields.ts} | 19 ++------ convex/_model/users/index.ts | 42 ++++++++++++++++ convex/_model/users/mutations/updateUser.ts | 34 +++++++++++++ .../users/mutations/updateUserAvatarNoAuth.ts | 21 ++++++++ convex/_model/users/queries/getCurrentUser.ts | 23 +++++++++ convex/_model/users/queries/getUser.ts | 29 +++++++++++ convex/_model/users/queries/getUsers.ts | 33 +++++++++++++ convex/auth.ts | 10 ++++ convex/schema.ts | 4 +- convex/users.ts | 37 ++++++++++++++ convex/users/checkUsernameExists.ts | 13 ----- convex/users/fetchCurrentUser.ts | 22 --------- convex/users/fetchUser.ts | 22 --------- convex/users/fetchUserList.ts | 21 -------- convex/users/updateAvatar.ts | 37 -------------- convex/users/updateUser.ts | 22 --------- .../utils/checkUserTournamentRelationship.ts | 30 ------------ convex/users/utils/getAvatarUrl.ts | 13 ----- convex/users/utils/getLimitedUser.ts | 21 -------- src/api.ts | 12 ++--- src/components/AuthProvider/AuthProvider.tsx | 2 +- .../FowV4MatchResultDetails.hooks.ts | 2 +- .../components/CommonFields.hooks.ts | 6 +-- .../components/SelectPlayerDialog.tsx | 6 +-- .../IdentityBadge/IdentityBadge.hooks.tsx | 15 ++---- .../UserProfileForm/UserProfileForm.tsx | 8 ++-- src/services/avatar/useUploadAvatar.ts | 6 +-- src/services/users.ts | 9 ++-- 38 files changed, 490 insertions(+), 318 deletions(-) create mode 100644 convex/_model/users/_helpers/checkUserAuth.ts create mode 100644 convex/_model/users/_helpers/checkUserTournamentRelationship.ts create mode 100644 convex/_model/users/_helpers/getShallowUser.ts rename convex/{users/utils/redactUserInfo.ts => _model/users/_helpers/redactUser.ts} (52%) create mode 100644 convex/_model/users/actions/setUserDefaultAvatar.ts rename convex/{users/index.ts => _model/users/fields.ts} (54%) create mode 100644 convex/_model/users/index.ts create mode 100644 convex/_model/users/mutations/updateUser.ts create mode 100644 convex/_model/users/mutations/updateUserAvatarNoAuth.ts create mode 100644 convex/_model/users/queries/getCurrentUser.ts create mode 100644 convex/_model/users/queries/getUser.ts create mode 100644 convex/_model/users/queries/getUsers.ts create mode 100644 convex/users.ts delete mode 100644 convex/users/checkUsernameExists.ts delete mode 100644 convex/users/fetchCurrentUser.ts delete mode 100644 convex/users/fetchUser.ts delete mode 100644 convex/users/fetchUserList.ts delete mode 100644 convex/users/updateAvatar.ts delete mode 100644 convex/users/updateUser.ts delete mode 100644 convex/users/utils/checkUserTournamentRelationship.ts delete mode 100644 convex/users/utils/getAvatarUrl.ts delete mode 100644 convex/users/utils/getLimitedUser.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 42cb2132..11df2d20 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -125,6 +125,18 @@ import type * as _model_tournaments_queries_getTournamentOpenRound from "../_mod import type * as _model_tournaments_queries_getTournamentRankings from "../_model/tournaments/queries/getTournamentRankings.js"; import type * as _model_tournaments_queries_getTournaments from "../_model/tournaments/queries/getTournaments.js"; import type * as _model_tournaments_queries_getTournamentsByStatus from "../_model/tournaments/queries/getTournamentsByStatus.js"; +import type * as _model_users__helpers_checkUserAuth from "../_model/users/_helpers/checkUserAuth.js"; +import type * as _model_users__helpers_checkUserTournamentRelationship from "../_model/users/_helpers/checkUserTournamentRelationship.js"; +import type * as _model_users__helpers_getShallowUser from "../_model/users/_helpers/getShallowUser.js"; +import type * as _model_users__helpers_redactUser from "../_model/users/_helpers/redactUser.js"; +import type * as _model_users_actions_setUserDefaultAvatar from "../_model/users/actions/setUserDefaultAvatar.js"; +import type * as _model_users_fields from "../_model/users/fields.js"; +import type * as _model_users_index from "../_model/users/index.js"; +import type * as _model_users_mutations_updateUser from "../_model/users/mutations/updateUser.js"; +import type * as _model_users_mutations_updateUserAvatarNoAuth from "../_model/users/mutations/updateUserAvatarNoAuth.js"; +import type * as _model_users_queries_getCurrentUser from "../_model/users/queries/getCurrentUser.js"; +import type * as _model_users_queries_getUser from "../_model/users/queries/getUser.js"; +import type * as _model_users_queries_getUsers from "../_model/users/queries/getUsers.js"; import type * as _model_utils__helpers_mockData from "../_model/utils/_helpers/mockData.js"; import type * as _model_utils__helpers_testUsers from "../_model/utils/_helpers/testUsers.js"; import type * as _model_utils_createTestTournament from "../_model/utils/createTestTournament.js"; @@ -171,17 +183,7 @@ import type * as tournamentCompetitors from "../tournamentCompetitors.js"; import type * as tournamentPairings from "../tournamentPairings.js"; import type * as tournamentTimers from "../tournamentTimers.js"; import type * as tournaments from "../tournaments.js"; -import type * as users_checkUsernameExists from "../users/checkUsernameExists.js"; -import type * as users_fetchCurrentUser from "../users/fetchCurrentUser.js"; -import type * as users_fetchUser from "../users/fetchUser.js"; -import type * as users_fetchUserList from "../users/fetchUserList.js"; -import type * as users_index from "../users/index.js"; -import type * as users_updateAvatar from "../users/updateAvatar.js"; -import type * as users_updateUser from "../users/updateUser.js"; -import type * as users_utils_checkUserTournamentRelationship from "../users/utils/checkUserTournamentRelationship.js"; -import type * as users_utils_getAvatarUrl from "../users/utils/getAvatarUrl.js"; -import type * as users_utils_getLimitedUser from "../users/utils/getLimitedUser.js"; -import type * as users_utils_redactUserInfo from "../users/utils/redactUserInfo.js"; +import type * as users from "../users.js"; import type * as utils from "../utils.js"; /** @@ -305,6 +307,18 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/queries/getTournamentRankings": typeof _model_tournaments_queries_getTournamentRankings; "_model/tournaments/queries/getTournaments": typeof _model_tournaments_queries_getTournaments; "_model/tournaments/queries/getTournamentsByStatus": typeof _model_tournaments_queries_getTournamentsByStatus; + "_model/users/_helpers/checkUserAuth": typeof _model_users__helpers_checkUserAuth; + "_model/users/_helpers/checkUserTournamentRelationship": typeof _model_users__helpers_checkUserTournamentRelationship; + "_model/users/_helpers/getShallowUser": typeof _model_users__helpers_getShallowUser; + "_model/users/_helpers/redactUser": typeof _model_users__helpers_redactUser; + "_model/users/actions/setUserDefaultAvatar": typeof _model_users_actions_setUserDefaultAvatar; + "_model/users/fields": typeof _model_users_fields; + "_model/users/index": typeof _model_users_index; + "_model/users/mutations/updateUser": typeof _model_users_mutations_updateUser; + "_model/users/mutations/updateUserAvatarNoAuth": typeof _model_users_mutations_updateUserAvatarNoAuth; + "_model/users/queries/getCurrentUser": typeof _model_users_queries_getCurrentUser; + "_model/users/queries/getUser": typeof _model_users_queries_getUser; + "_model/users/queries/getUsers": typeof _model_users_queries_getUsers; "_model/utils/_helpers/mockData": typeof _model_utils__helpers_mockData; "_model/utils/_helpers/testUsers": typeof _model_utils__helpers_testUsers; "_model/utils/createTestTournament": typeof _model_utils_createTestTournament; @@ -351,17 +365,7 @@ declare const fullApi: ApiFromModules<{ tournamentPairings: typeof tournamentPairings; tournamentTimers: typeof tournamentTimers; tournaments: typeof tournaments; - "users/checkUsernameExists": typeof users_checkUsernameExists; - "users/fetchCurrentUser": typeof users_fetchCurrentUser; - "users/fetchUser": typeof users_fetchUser; - "users/fetchUserList": typeof users_fetchUserList; - "users/index": typeof users_index; - "users/updateAvatar": typeof users_updateAvatar; - "users/updateUser": typeof users_updateUser; - "users/utils/checkUserTournamentRelationship": typeof users_utils_checkUserTournamentRelationship; - "users/utils/getAvatarUrl": typeof users_utils_getAvatarUrl; - "users/utils/getLimitedUser": typeof users_utils_getLimitedUser; - "users/utils/redactUserInfo": typeof users_utils_redactUserInfo; + users: typeof users; utils: typeof utils; }>; export declare const api: FilterApi< diff --git a/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts b/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts index 3dd7fd4b..2ebb515c 100644 --- a/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts +++ b/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts @@ -1,6 +1,9 @@ +import { ConvexError } from 'convex/values'; + import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getLimitedUser } from '../../../users/utils/getLimitedUser'; +import { getErrorMessage } from '../../../common/errors'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -17,9 +20,9 @@ export const deepenMatchResultComment = async ( ctx: QueryCtx, matchResultComment: Doc<'matchResultComments'>, ) => { - const user = await getLimitedUser(ctx, matchResultComment.userId); + const user = await getUser(ctx, { id: matchResultComment.userId }); if (!user) { - throw Error('Could not find user!'); + throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); } return { ...matchResultComment, diff --git a/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts b/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts index 84f55799..8123f73c 100644 --- a/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts +++ b/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts @@ -1,6 +1,9 @@ +import { ConvexError } from 'convex/values'; + import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getLimitedUser } from '../../../users/utils/getLimitedUser'; +import { getErrorMessage } from '../../../common/errors'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -17,9 +20,9 @@ export const deepenMatchResultLike = async ( ctx: QueryCtx, matchResultLike: Doc<'matchResultLikes'>, ) => { - const user = await getLimitedUser(ctx, matchResultLike.userId); + const user = await getUser(ctx, { id: matchResultLike.userId }); if (!user) { - throw Error('Could not find user!'); + throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); } return { ...matchResultLike, diff --git a/convex/_model/matchResults/_helpers/deepenMatchResult.ts b/convex/_model/matchResults/_helpers/deepenMatchResult.ts index 4a7f3346..1ef3a542 100644 --- a/convex/_model/matchResults/_helpers/deepenMatchResult.ts +++ b/convex/_model/matchResults/_helpers/deepenMatchResult.ts @@ -1,7 +1,7 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getLimitedUser } from '../../../users/utils/getLimitedUser'; import { getMission } from '../../fowV4/getMission'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -18,8 +18,12 @@ export const deepenMatchResult = async ( ctx: QueryCtx, matchResult: Doc<'matchResults'>, ) => { - const player0User = await getLimitedUser(ctx, matchResult?.player0UserId); - const player1User = await getLimitedUser(ctx, matchResult?.player1UserId); + const player0User = matchResult?.player0UserId ? await getUser(ctx, { + id: matchResult.player0UserId, + }) : null; + const player1User = matchResult?.player1UserId ? await getUser(ctx, { + id: matchResult.player1UserId, + }) : null; const mission = getMission(matchResult.details.missionId); const comments = await ctx.db.query('matchResultComments') .withIndex('by_match_result_id',((q) => q.eq('matchResultId', matchResult._id))) diff --git a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts index 5cc340b0..1f3778ad 100644 --- a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts @@ -1,7 +1,7 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { redactUserInfo } from '../../../users/utils/redactUserInfo'; -import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; +import { LimitedUser } from '../../users/_helpers/redactUser'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -18,27 +18,18 @@ export const deepenTournamentCompetitor = async ( ctx: QueryCtx, tournamentCompetitor: Doc<'tournamentCompetitors'>, ) => { - const players = await Promise.all(tournamentCompetitor.players.map(async ({ active, userId }) => { - // TODO: Replace with proper user helper - const user = await ctx.db.get(userId); - if (!user) { - return { - active, - user: null, - }; - } - const avatarUrl = await getStorageUrl(ctx, user.avatarStorageId); - return { - active, - user: { - ...await redactUserInfo(ctx, user), - avatarUrl, - }, - }; - })); + const players = await Promise.all(tournamentCompetitor.players.map(async ({ active, userId }) => ({ + active, + user: await getUser(ctx, { id: userId }), + }))); + + function playerHasUser(player: { active: boolean; user: LimitedUser | null }): player is { active: boolean; user: LimitedUser } { + return player.user !== null; + } + return { ...tournamentCompetitor, - players: players.filter((player) => !!player.user), + players: players.filter(playerHasUser), }; }; diff --git a/convex/_model/users/_helpers/checkUserAuth.ts b/convex/_model/users/_helpers/checkUserAuth.ts new file mode 100644 index 00000000..3f9992bc --- /dev/null +++ b/convex/_model/users/_helpers/checkUserAuth.ts @@ -0,0 +1,28 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; +import { ConvexError } from 'convex/values'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../../common/errors'; + +/** + * Checks if a user has permission to perform actions on a user. + * Throws an error if: + * - The user is not authenticated; + * - The user is attempting to edit another user; + * + * @param ctx - Convex query context + * @param user - Raw user document + */ +export const checkUserAuth = async ( + ctx: QueryCtx, + user: Doc<'users'>, // TODO: Make union with TournamentDeep +): Promise => { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new ConvexError(getErrorMessage('USER_NOT_AUTHENTICATED')); + } + if (userId !== user._id) { + throw new ConvexError(getErrorMessage('USER_DOES_NOT_HAVE_PERMISSION')); + } +}; diff --git a/convex/_model/users/_helpers/checkUserTournamentRelationship.ts b/convex/_model/users/_helpers/checkUserTournamentRelationship.ts new file mode 100644 index 00000000..716a4d78 --- /dev/null +++ b/convex/_model/users/_helpers/checkUserTournamentRelationship.ts @@ -0,0 +1,30 @@ +import { Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getTournamentUserIds } from '../../../_model/tournaments'; + +export const checkUserTournamentRelationship = async ( + ctx: QueryCtx, + userIdA?: Id<'users'> | null, + userIdB?: Id<'users'> | null, +): Promise => { + if (!userIdA || !userIdB) { + return false; + } + + const tournaments = await ctx.db.query('tournaments').collect(); + + // Check each tournament for a relationship, return true if one is found + return tournaments.some(async ({ _id, organizerUserIds }) => { + + const playerUserIds = await getTournamentUserIds(ctx, _id); + + // Merge all organizer IDs and player IDs into one set + const allTournamentUserIds = new Set([ + ...organizerUserIds, + ...playerUserIds, + ]); + + // If the set contains both user IDs, they were at the same tournament + return allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB); + }); +}; diff --git a/convex/_model/users/_helpers/getShallowUser.ts b/convex/_model/users/_helpers/getShallowUser.ts new file mode 100644 index 00000000..b2c5c354 --- /dev/null +++ b/convex/_model/users/_helpers/getShallowUser.ts @@ -0,0 +1,28 @@ +import { ConvexError } from 'convex/values'; + +import { Doc, Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../../common/errors'; + +/** + * Gets a user from the database without joining any additional data (shallow). + * + * @remarks + * This method should be used when you KNOW a user exists. + * This is almost always the case. + * If the document does not exist in the database, this function will throw an error. + * + * @param ctx - Convex query context + * @param id - ID of the user + * @returns A raw user document + */ +export const getShallowUser = async ( + ctx: QueryCtx, + id: Id<'users'>, +): Promise> => { + const user = await ctx.db.get(id); + if (!user) { + throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); + } + return user; +}; diff --git a/convex/users/utils/redactUserInfo.ts b/convex/_model/users/_helpers/redactUser.ts similarity index 52% rename from convex/users/utils/redactUserInfo.ts rename to convex/_model/users/_helpers/redactUser.ts index cdfd77cf..d3e001ef 100644 --- a/convex/users/utils/redactUserInfo.ts +++ b/convex/_model/users/_helpers/redactUser.ts @@ -1,33 +1,55 @@ import { getAuthUserId } from '@convex-dev/auth/server'; -import { Doc } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; import { checkUserTournamentRelationship } from './checkUserTournamentRelationship'; -export const redactUserInfo = async ( +/** + * User with some personal information hidden based on their preferences. + */ +export type LimitedUser = Omit, 'givenName' | 'familyName' | 'countryCode'> & { + givenName?: string; + familyName?: string; + countryCode?: string; + avatarUrl?: string; +}; + +/** + * Removes a users's real name or location based on their preferences, also adds avatarUrl. + * + * @remarks + * This is essentially the user equivalent to the deepen[Resource]() pattern. + * + * @param ctx - Convex query context + * @param tournament - Raw user document + * @returns A limited user + */ +export const redactUser = async ( ctx: QueryCtx, user: Doc<'users'>, -): Promise> => { - const queryingUserId = await getAuthUserId(ctx); +): Promise => { + const userId = await getAuthUserId(ctx); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { givenName, familyName, countryCode, ...restFields } = user; + const avatarUrl = await getStorageUrl(ctx, user.avatarStorageId); - const limitedUser: Doc<'users'> = restFields; + const limitedUser: LimitedUser = { + ...restFields, + avatarUrl, + }; // If user is querying own profile, simply return it - if (queryingUserId === user._id) { - return user; + if (userId === user._id) { + return { ...user, avatarUrl }; } // If user is querying someone they are in a friendship or club with const hasFriendRelationship = false; // If user is querying someone they are in a tournament with - const hasTournamentRelationship = (queryingUserId && limitedUser?._id) ? await checkUserTournamentRelationship(ctx, { - queryingUserId, - evaluatingUserId: user._id, - }) : false; + const hasTournamentRelationship = await checkUserTournamentRelationship(ctx, userId, user._id); // Add name information if allowed if ( diff --git a/convex/_model/users/actions/setUserDefaultAvatar.ts b/convex/_model/users/actions/setUserDefaultAvatar.ts new file mode 100644 index 00000000..e0de9131 --- /dev/null +++ b/convex/_model/users/actions/setUserDefaultAvatar.ts @@ -0,0 +1,48 @@ +'use node'; + +import { Infer, v } from 'convex/values'; + +import { api, internal } from '../../../_generated/api'; +import { ActionCtx } from '../../../_generated/server'; + +export const setUserDefaultAvatarArgs = v.object({ + userId: v.id('users'), +}); + +export const setUserDefaultAvatar = async ( + ctx: ActionCtx, + args: Infer, +): Promise => { + const user = await ctx.runQuery(api.users.getUser, { + id: args.userId, + }); + if (!user || !!user.avatarStorageId) { + return; + } + + // Fetch avatar + const avatarUrl = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${args.userId}&scale=75`; + const avatarResponse = await fetch(avatarUrl); + if (!avatarResponse.ok) { + throw new Error('Failed to fetch avatar'); + } + const svg = await avatarResponse.arrayBuffer(); + + // Upload avatar + const uploadUrl = await ctx.storage.generateUploadUrl(); + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': 'image/svg+xml' }, + body: svg, + }); + if (!uploadResponse.ok) { + throw new Error('Failed to upload avatar to storage'); + } + const { storageId: avatarStorageId } = await uploadResponse.json(); + + // Update the user profile document with the new avatar + await ctx.runMutation(internal.users.updateUserAvatarNoAuth, { + userId: args.userId, + avatarStorageId, + }); +}; diff --git a/convex/users/index.ts b/convex/_model/users/fields.ts similarity index 54% rename from convex/users/index.ts rename to convex/_model/users/fields.ts index e92caa44..62ff0d31 100644 --- a/convex/users/index.ts +++ b/convex/_model/users/fields.ts @@ -1,9 +1,8 @@ -import { defineTable } from 'convex/server'; import { v } from 'convex/values'; -import { userDataVisibilityLevel } from '../common/userDataVisibilityLevel'; +import { userDataVisibilityLevel } from '../../common/userDataVisibilityLevel'; -export const fields = { +export const editableFields = { avatarStorageId: v.optional(v.id('_storage')), countryCode: v.optional(v.string()), familyName: v.optional(v.string()), @@ -13,20 +12,8 @@ export const fields = { username: v.optional(v.string()), }; -export const table = defineTable({ - ...fields, +export const computedFields = { email: v.string(), emailVerificationTime: v.optional(v.number()), modifiedAt: v.optional(v.number()), -}).index( - 'by_country_code', ['countryCode'], -).index( - 'by_name', ['givenName', 'familyName'], -).index( - 'by_username', ['username'], -); - -export { - fields as userFields, - table as users, }; diff --git a/convex/_model/users/index.ts b/convex/_model/users/index.ts new file mode 100644 index 00000000..4bac0ba1 --- /dev/null +++ b/convex/_model/users/index.ts @@ -0,0 +1,42 @@ +import { defineTable } from 'convex/server'; + +import { Id } from '../../_generated/dataModel'; +import { computedFields, editableFields } from './fields'; + +export const usersTable = defineTable({ + ...editableFields, + ...computedFields, +}) + .index('by_country_code', ['countryCode']) + .index('by_name', ['givenName', 'familyName']) + .index('by_username', ['username']); + +export type UserId = Id<'users'>; + +export { + type LimitedUser, + redactUser, +} from './_helpers/redactUser'; +export { + setUserDefaultAvatar, + setUserDefaultAvatarArgs, +} from './actions/setUserDefaultAvatar'; +export { + updateUser, + updateUserArgs, +} from './mutations/updateUser'; +export { + updateUserAvatarNoAuth, + updateUserAvatarNoAuthArgs, +} from './mutations/updateUserAvatarNoAuth'; +export { + getCurrentUser, +} from './queries/getCurrentUser'; +export { + getUser, + getUserArgs, +} from './queries/getUser'; +export { + getUsers, + getUsersArgs, +} from './queries/getUsers'; diff --git a/convex/_model/users/mutations/updateUser.ts b/convex/_model/users/mutations/updateUser.ts new file mode 100644 index 00000000..3f02fc42 --- /dev/null +++ b/convex/_model/users/mutations/updateUser.ts @@ -0,0 +1,34 @@ +import { Infer, v } from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; +import { checkUserAuth } from '../_helpers/checkUserAuth'; +import { getShallowUser } from '../_helpers/getShallowUser'; +import { editableFields } from '../fields'; + +export const updateUserArgs = v.object({ + id: v.id('users'), + ...editableFields, +}); + +/** + * Updates a user. + * + * @param ctx - Convex query context + * @param args - User data + */ +export const updateUser = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const { id, ...updated } = args; + const user = await getShallowUser(ctx, id); + + // --- CHECK AUTH ---- + checkUserAuth(ctx, user); + + // ---- PRIMARY ACTIONS ---- + await ctx.db.patch(id, { + ...updated, + modifiedAt: Date.now(), + }); +}; diff --git a/convex/_model/users/mutations/updateUserAvatarNoAuth.ts b/convex/_model/users/mutations/updateUserAvatarNoAuth.ts new file mode 100644 index 00000000..1d033306 --- /dev/null +++ b/convex/_model/users/mutations/updateUserAvatarNoAuth.ts @@ -0,0 +1,21 @@ +import { Infer, v } from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; + +export const updateUserAvatarNoAuthArgs = v.object({ + userId: v.id('users'), + avatarStorageId: v.id('_storage'), +}); + +export const updateUserAvatarNoAuth = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const user = await ctx.db.get(args.userId); + if (!user) { + throw 'Could not find a user by that ID.'; + } + await ctx.db.patch(args.userId, { + avatarStorageId: args.avatarStorageId, + }); +}; diff --git a/convex/_model/users/queries/getCurrentUser.ts b/convex/_model/users/queries/getCurrentUser.ts new file mode 100644 index 00000000..5e6b3543 --- /dev/null +++ b/convex/_model/users/queries/getCurrentUser.ts @@ -0,0 +1,23 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { QueryCtx } from '../../../_generated/server'; +import { LimitedUser } from '../_helpers/redactUser'; +import { getUser } from './getUser'; + +/** + * Gets the querying user. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.id - ID of the user + * @returns - A limited user if found, otherwise null + */ +export const getCurrentUser = async ( + ctx: QueryCtx, +): Promise => { + const userId = await getAuthUserId(ctx); + if (!userId) { + return null; + } + return await getUser(ctx, { id: userId }); +}; diff --git a/convex/_model/users/queries/getUser.ts b/convex/_model/users/queries/getUser.ts new file mode 100644 index 00000000..b28aaed2 --- /dev/null +++ b/convex/_model/users/queries/getUser.ts @@ -0,0 +1,29 @@ +import { Infer, v } from 'convex/values'; + +import { QueryCtx } from '../../../_generated/server'; +import { LimitedUser, redactUser } from '../_helpers/redactUser'; + +export const getUserArgs = v.object({ + id: v.id('users'), +}); + +/** + * Gets a user by ID. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.id - ID of the user + * @returns - A limited user if found, otherwise null + */ +export const getUser = async ( + ctx: QueryCtx, + args: Infer, +): Promise => { + const user = await ctx.db.get(args.id); + if (!user) { + return null; + } + + // --- CHECK AUTH ---- + return await redactUser(ctx, user); +}; diff --git a/convex/_model/users/queries/getUsers.ts b/convex/_model/users/queries/getUsers.ts new file mode 100644 index 00000000..141762ca --- /dev/null +++ b/convex/_model/users/queries/getUsers.ts @@ -0,0 +1,33 @@ +import { Infer, v } from 'convex/values'; + +import { QueryCtx } from '../../../_generated/server'; +import { filterWithSearchTerm } from '../../common/_helpers/filterWithSearchTerm'; +import { LimitedUser, redactUser } from '../_helpers/redactUser'; + +export const getUsersArgs = v.object({ + search: v.optional(v.string()), +}); + +/** + * Gets an array of limited users. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.search - Search text + * @returns An array of limited users + */ +export const getUsers = async ( + ctx: QueryCtx, + args: Infer, +): Promise => { + const users = await ctx.db.query('users').collect(); + const limitedUsers = await Promise.all(users.map(async (user) => await redactUser(ctx, user))); + if (args.search) { + return filterWithSearchTerm(limitedUsers, args.search, [ + 'familyName', + 'givenName', + 'username', + ]); + } + return limitedUsers; +}; diff --git a/convex/auth.ts b/convex/auth.ts index bfb71f16..1a3ede2a 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,6 +1,8 @@ import { Password } from '@convex-dev/auth/providers/Password'; import { convexAuth } from '@convex-dev/auth/server'; +import { api } from './_generated/api'; +import { MutationCtx } from './_generated/server'; import { ResendOtpPasswordReset } from './auth/ResendOtpPasswordReset'; import { UserDataVisibilityLevel } from './common/userDataVisibilityLevel'; @@ -34,4 +36,12 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ // verify: ResendOtpVerification, }), ], + callbacks: { + afterUserCreatedOrUpdated: async (ctx: MutationCtx, { userId }) => { + // Use scheduler so that we can trigger an action rather than a mutation: + await ctx.scheduler.runAfter(0, api.users.setUserDefaultAvatar, { + userId, + }); + }, + }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 771d569f..d5a8102a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -8,9 +8,9 @@ import { tournamentCompetitorsTable } from './_model/tournamentCompetitors'; import { tournamentPairingsTable } from './_model/tournamentPairings'; import { tournamentsTable } from './_model/tournaments'; import { tournamentTimersTable } from './_model/tournamentTimers'; +import { usersTable } from './_model/users'; import { friendships } from './friendships'; import { photos } from './photos'; -import { users } from './users'; export default defineSchema({ ...authTables, @@ -23,5 +23,5 @@ export default defineSchema({ tournamentPairings: tournamentPairingsTable, tournaments: tournamentsTable, tournamentTimers: tournamentTimersTable, - users, + users: usersTable, }); diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 00000000..3bc00c47 --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,37 @@ +import { + action, + internalMutation, + mutation, + query, +} from './_generated/server'; +import * as model from './_model/users'; + +// INTERNAL MUTATIONS +export const updateUserAvatarNoAuth = internalMutation({ + args: model.updateUserAvatarNoAuthArgs, + handler: model.updateUserAvatarNoAuth, +}); + +export const setUserDefaultAvatar = action({ + args: model.setUserDefaultAvatarArgs, + handler: model.setUserDefaultAvatar, +}); + +export const getCurrentUser = query({ + handler: model.getCurrentUser, +}); + +export const getUser = query({ + args: model.getUserArgs, + handler: model.getUser, +}); + +export const getUsers = query({ + args: model.getUsersArgs, + handler: model.getUsers, +}); + +export const updateUser = mutation({ + args: model.updateUserArgs, + handler: model.updateUser, +}); diff --git a/convex/users/checkUsernameExists.ts b/convex/users/checkUsernameExists.ts deleted file mode 100644 index aa3f5377..00000000 --- a/convex/users/checkUsernameExists.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { v } from 'convex/values'; - -import { query } from '../_generated/server'; - -export const checkUsernameExists = query({ - args: { - username: v.string(), - }, - handler: async (ctx, args) => { - const user = await ctx.db.query('users').withIndex('by_username', (q) => q.eq('username', args.username)).first(); - return Boolean(user); - }, -}); diff --git a/convex/users/fetchCurrentUser.ts b/convex/users/fetchCurrentUser.ts deleted file mode 100644 index e1deef99..00000000 --- a/convex/users/fetchCurrentUser.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getAuthUserId } from '@convex-dev/auth/server'; - -import { getAvatarUrl } from './utils/getAvatarUrl'; -import { query } from '../_generated/server'; - -export const fetchCurrentUser = query({ - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) { - return null; - } - const user = await ctx.db.get(userId); - if (!user) { - return null; - } - const avatarUrl = await getAvatarUrl(ctx, user.avatarStorageId); - return { - ...user, - avatarUrl, - }; - }, -}); diff --git a/convex/users/fetchUser.ts b/convex/users/fetchUser.ts deleted file mode 100644 index d68b9ac8..00000000 --- a/convex/users/fetchUser.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { v } from 'convex/values'; - -import { getAvatarUrl } from './utils/getAvatarUrl'; -import { getLimitedUser } from './utils/getLimitedUser'; -import { query } from '../_generated/server'; - -export const fetchUser = query({ - args: { - id: v.id('users'), - }, - handler: async (ctx, { id }) => { - const user = await getLimitedUser(ctx, id); - if (!user) { - return null; - } - const avatarUrl = await getAvatarUrl(ctx, user.avatarStorageId); - return { - ...user, - avatarUrl, - }; - }, -}); diff --git a/convex/users/fetchUserList.ts b/convex/users/fetchUserList.ts deleted file mode 100644 index 199bc926..00000000 --- a/convex/users/fetchUserList.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { v } from 'convex/values'; - -import { getAvatarUrl } from './utils/getAvatarUrl'; -import { redactUserInfo } from './utils/redactUserInfo'; -import { query } from '../_generated/server'; -import { filterWithSearchTerm } from '../_model/common/_helpers/filterWithSearchTerm'; - -export const fetchUserList = query({ - args: { - // TODO: Add tournamentId, tournamentCompetitorId, clubId - search: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const users = await ctx.db.query('users').collect(); - const results = await Promise.all(users.map(async (u) => ({ - ...await redactUserInfo(ctx, u), - avatarUrl: await getAvatarUrl(ctx, u.avatarStorageId), - }))); - return filterWithSearchTerm(results, args.search ?? '', ['familyName', 'givenName', 'username']); - }, -}); diff --git a/convex/users/updateAvatar.ts b/convex/users/updateAvatar.ts deleted file mode 100644 index bea82dfe..00000000 --- a/convex/users/updateAvatar.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getAuthUserId } from '@convex-dev/auth/server'; -import { v } from 'convex/values'; - -import { mutation } from '../_generated/server'; - -export const updateAvatar = mutation({ - args: { - userId: v.id('users'), - avatarStorageId: v.id('_storage'), - }, - handler: async (ctx, { userId, avatarStorageId }) => { - const queryingUserId = await getAuthUserId(ctx); - - if (userId !== queryingUserId) { - throw 'Cannot modify another user\'s avatar.'; - } - - const user = await ctx.db.get(userId); - - if (!user) { - throw 'Could not find a user by that ID.'; - } - - const existingAvatarStorageId = user?.avatarStorageId; - - // Add the new storage ID to the user - await ctx.db.patch(userId, { - avatarStorageId, - modifiedAt: Date.now(), - }); - - // Delete the old file to clean-up - if (existingAvatarStorageId) { - await ctx.storage.delete(existingAvatarStorageId); - } - }, -}); diff --git a/convex/users/updateUser.ts b/convex/users/updateUser.ts deleted file mode 100644 index fee6b725..00000000 --- a/convex/users/updateUser.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getAuthUserId } from '@convex-dev/auth/server'; -import { v } from 'convex/values'; - -import { mutation } from '../_generated/server'; -import { fields } from '.'; - -export const updateUser = mutation({ - args: { - ...fields, - id: v.id('users'), - }, - handler: async (ctx, { id, ...args }) => { - const queryingUserId = await getAuthUserId(ctx); - if (id !== queryingUserId) { - throw 'Cannot modify another user\'s profile!'; - } - await ctx.db.patch(id, { - ...args, - modifiedAt: Date.now(), - }); - }, -}); diff --git a/convex/users/utils/checkUserTournamentRelationship.ts b/convex/users/utils/checkUserTournamentRelationship.ts deleted file mode 100644 index d8cf2f8f..00000000 --- a/convex/users/utils/checkUserTournamentRelationship.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Id } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; -import { getTournamentUserIds } from '../../_model/tournaments'; - -type CheckUserTournamentRelationshipArgs = { - queryingUserId: Id<'users'>; - evaluatingUserId: Id<'users'>; -}; - -export const checkUserTournamentRelationship = async ( - ctx: QueryCtx, - args: CheckUserTournamentRelationshipArgs, -): Promise => { - const tournaments = await ctx.db.query('tournaments').collect(); - - // Check each tournament for a relationship, return true if one is found - return tournaments.some(async (tournament) => { - - const competitorUserIds = await getTournamentUserIds(ctx, tournament._id); - - // Merge all organizer IDs and player IDs into one set - const allTournamentUserIds = new Set([ - ...tournament.organizerUserIds, - ...competitorUserIds, - ]); - - // If the set contains both user IDs, they were at the same tournament - return allTournamentUserIds.has(args.evaluatingUserId) && allTournamentUserIds.has(args.queryingUserId); - }); -}; diff --git a/convex/users/utils/getAvatarUrl.ts b/convex/users/utils/getAvatarUrl.ts deleted file mode 100644 index a9e0d9df..00000000 --- a/convex/users/utils/getAvatarUrl.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Id } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; - -export const getAvatarUrl = async (ctx: QueryCtx, storageId?: Id<'_storage'>): Promise => { - if (!storageId) { - return undefined; - } - const avatarUrl = await ctx.storage.getUrl(storageId); - if (avatarUrl) { - return avatarUrl; - } - return undefined; -}; diff --git a/convex/users/utils/getLimitedUser.ts b/convex/users/utils/getLimitedUser.ts deleted file mode 100644 index 124a1992..00000000 --- a/convex/users/utils/getLimitedUser.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Doc, Id } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; -import { redactUserInfo } from './redactUserInfo'; - -export const getLimitedUser = async ( - ctx: QueryCtx, - id?: Id<'users'>, -): Promise & { avatarUrl?: string } | undefined> => { - if (!id) { - return undefined; - } - const user = await ctx.db.get(id); - if (!user) { - return undefined; - } - const avatarUrl = user.avatarStorageId ? await ctx.storage.getUrl(user.avatarStorageId) : undefined; - return { - ...await redactUserInfo(ctx, user), - avatarUrl: avatarUrl || undefined, - }; -}; diff --git a/src/api.ts b/src/api.ts index b92c82bd..0647faf2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,5 @@ import { api } from '../convex/_generated/api'; -import { Doc, Id } from '../convex/_generated/dataModel'; +import { Id } from '../convex/_generated/dataModel'; // Re-export API for usage in services export { api }; @@ -56,12 +56,10 @@ export { } from '../convex/_model/tournaments'; // Users -export type User = Doc<'users'> & { - avatarUrl?: string; -}; -export type UserId = Id<'users'>; -export type UpdateUserInput = typeof api.users.updateUser.updateUser._args; -export type UpdateUserResponse = typeof api.users.updateUser.updateUser._returnType; +export { + type LimitedUser as User, + type UserId, +} from '../convex/_model/users'; // Static Data & Interfaces (Common) export { diff --git a/src/components/AuthProvider/AuthProvider.tsx b/src/components/AuthProvider/AuthProvider.tsx index 1a274b07..cd86841b 100644 --- a/src/components/AuthProvider/AuthProvider.tsx +++ b/src/components/AuthProvider/AuthProvider.tsx @@ -19,7 +19,7 @@ export interface AuthProviderProps { export const AuthProvider = ({ children, }: AuthProviderProps) => { - const user = useQuery(api.users.fetchCurrentUser.fetchCurrentUser); + const user = useQuery(api.users.getCurrentUser); const [loading, setLoading] = useState(true); const [updatePasswordDialogOpen, setUpdatePasswordDialogOpen] = useState(false); diff --git a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts index 24de9cde..243b5356 100644 --- a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts +++ b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts @@ -18,7 +18,7 @@ export const usePlayerName = ( const { user, userId, placeholder } = identity; // TODO: Replace with a service hook - const queryUser = useQuery(api.users.fetchUser.fetchUser, userId ? { + const queryUser = useQuery(api.users.getUser, userId ? { id: userId, } : 'skip'); diff --git a/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts b/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts index 27d3d928..79edc529 100644 --- a/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts +++ b/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts @@ -1,8 +1,6 @@ import { useFormContext } from 'react-hook-form'; -import { useQuery } from 'convex/react'; import { - api, FowV4MatchOutcomeType, getMission, getMissionPack, @@ -13,7 +11,7 @@ import { getUserDisplayNameString } from '~/utils/common/getUserDisplayNameStrin export const usePlayerDisplayName = ({ userId, placeholder }: { userId?: UserId, placeholder?: string }): string => { const currentUser = useAuth(); - const user = useQuery(api.users.fetchUser.fetchUser, userId ? { id: userId } : 'skip'); + const { data: user } = useGetUser(userId ? { id: userId } : 'skip'); if (!userId && placeholder) { return placeholder; } @@ -43,6 +41,8 @@ export const usePlayerOptions = (): { value: number, label: string }[] => { import { useMemo } from 'react'; +import { useGetUser } from '~/services/users'; + export const useMissionOptions = () => { const { watch } = useFormContext(); const { details, gameSystemConfig } = watch(); diff --git a/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx b/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx index 99c9b1dd..39a89788 100644 --- a/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx +++ b/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx @@ -1,9 +1,8 @@ import { ChangeEvent, useState } from 'react'; import { Close } from '@radix-ui/react-dialog'; -import { useQuery } from 'convex/react'; import { Search } from 'lucide-react'; -import { api, UserId } from '~/api'; +import { UserId } from '~/api'; import { useAuth } from '~/components/AuthProvider'; import { Avatar } from '~/components/generic/Avatar'; import { Button } from '~/components/generic/Button'; @@ -12,6 +11,7 @@ import { InputText } from '~/components/generic/InputText'; import { Label } from '~/components/generic/Label'; import { ScrollArea } from '~/components/generic/ScrollArea'; import { Separator } from '~/components/generic/Separator'; +import { useGetUsers } from '~/services/users'; import { getUserDisplayNameReact } from '~/utils/common/getUserDisplayNameReact'; import styles from './SelectPlayerDialog.module.scss'; @@ -30,7 +30,7 @@ export const SelectPlayerDialog = ({ disabled = false, }: SelectMatchResultPlayerDialogProps): JSX.Element => { const user = useAuth(); - const users = useQuery(api.users.fetchUserList.fetchUserList, {}); + const { data: users } = useGetUsers({}); const selectableUsers = (users || []).filter((u) => u._id !== user?._id && userId !== u._id); const existingUser = (users || []).find((u) => u._id === userId); diff --git a/src/components/IdentityBadge/IdentityBadge.hooks.tsx b/src/components/IdentityBadge/IdentityBadge.hooks.tsx index 44d09420..24ebc8ec 100644 --- a/src/components/IdentityBadge/IdentityBadge.hooks.tsx +++ b/src/components/IdentityBadge/IdentityBadge.hooks.tsx @@ -1,10 +1,11 @@ import { ReactElement } from 'react'; -import { useQuery } from 'convex/react'; import { Ghost, HelpCircle } from 'lucide-react'; -import { api, TournamentCompetitor } from '~/api'; +import { TournamentCompetitor } from '~/api'; import { Avatar } from '~/components/generic/Avatar'; import { FlagCircle } from '~/components/generic/FlagCircle'; +import { useGetTournamentCompetitor } from '~/services/tournamentCompetitors'; +import { useGetUser } from '~/services/users'; import { getCountryName } from '~/utils/common/getCountryName'; import { getUserDisplayNameString } from '~/utils/common/getUserDisplayNameString'; import { Identity } from './IdentityBadge.types'; @@ -40,14 +41,8 @@ const getCompetitorDisplayName = (competitor: TournamentCompetitor): ReactElemen export const useIdentityElements = (identity: Identity, loading?: boolean): ReactElement[] => { const { user, userId, competitor, competitorId, placeholder } = identity; - // TODO: Replace with a service hook - const queryUser = useQuery(api.users.fetchUser.fetchUser, userId ? { - id: userId, - } : 'skip'); - // TODO: Replace with a service hook - const queryCompetitor = useQuery(api.tournamentCompetitors.getTournamentCompetitor, competitorId ? { - id: competitorId, - } : 'skip'); + const { data: queryUser } = useGetUser(userId ? { id: userId } : 'skip'); + const { data: queryCompetitor } = useGetTournamentCompetitor(competitorId ? { id: competitorId } : 'skip'); // Render loading skeleton if explicitly loading or an ID was provided and it is fetching if (loading || (userId && !queryUser) || competitorId && !queryCompetitor) { diff --git a/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx b/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx index 22324aa0..1a99a702 100644 --- a/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx +++ b/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx @@ -22,7 +22,9 @@ import styles from './UserProfileForm.module.scss'; export const UserProfileForm = (): JSX.Element => { const user = useAuth(); - const { mutation: updateUser } = useUpdateUser(); + const { mutation: updateUser } = useUpdateUser({ + successMessage: 'Profile updated!', + }); const form = useForm({ resolver: zodResolver(userProfileFormSchema), @@ -54,19 +56,17 @@ export const UserProfileForm = (): JSX.Element => { - {/*

About Name Privacy

You can configure if you want your name to be hidden, visible to friends, tournament organizers & participants, or public. A brief explanation of what these levels mean is as follows:

  • Hidden: No one but you can view your name.
  • -
  • Friends: Only you and users you've added as a friend can view your name.
  • + {/*
  • Friends: Only you and users you've added as a friend can view your name.
  • */}
  • Tournaments: Only you, and the participants and organizers of tournaments you attend can view your name.
  • Public: Your name will be publicly visible.

To everyone who can't view your name, your username will be displayed.

Keep in mind, some tournaments (such as the European Team Championship) require players to use their full name. If you register for a tournament which requires this, you will be prompted to increase your setting to 'Tournament' if it is set to 'Hidden' or 'Friends'.

- */} diff --git a/src/services/avatar/useUploadAvatar.ts b/src/services/avatar/useUploadAvatar.ts index a1ba80a4..c9120ade 100644 --- a/src/services/avatar/useUploadAvatar.ts +++ b/src/services/avatar/useUploadAvatar.ts @@ -52,7 +52,7 @@ export const useUploadAvatar = () => { const [isLoading, setLoading] = useState(false); const generateUploadUrl = useMutation(api.generateFileUploadUrl.generateFileUploadUrl); - const updateAvatar = useMutation(api.users.updateAvatar.updateAvatar); + const updateUser = useMutation(api.users.updateUser); return { uploadAvatar: async (userId: UserId, file: File) => { @@ -78,8 +78,8 @@ export const useUploadAvatar = () => { const { storageId } = await uploadResponse.json(); // Get Convex file ID - await updateAvatar({ - userId, + await updateUser({ + id: userId, avatarStorageId: storageId, }); } catch (error) { diff --git a/src/services/users.ts b/src/services/users.ts index 6ba7e82b..5f36c01a 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -2,8 +2,11 @@ import { api } from '~/api'; import { createMutationHook, createQueryHook } from '~/services/utils'; // Basic Queries -export const useGetUser = createQueryHook(api.users.fetchUser.fetchUser); -export const useGetUsers = createQueryHook(api.users.fetchUserList.fetchUserList); +export const useGetUser = createQueryHook(api.users.getUser); +export const useGetUsers = createQueryHook(api.users.getUsers); + +// Special Queries +export const useGetCurrentUser = createQueryHook(api.users.getCurrentUser); // Basic (C_UD) Mutations -export const useUpdateUser = createMutationHook(api.users.updateUser.updateUser); +export const useUpdateUser = createMutationHook(api.users.updateUser); From 85fcad111d2af563143b084f0efa9b435cd3c204 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 22:28:28 +0200 Subject: [PATCH 06/31] feat: Improve default tab --- src/pages/TournamentDetailPage/TournamentDetailPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx index 26e27b27..d9c26eb2 100644 --- a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx +++ b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx @@ -74,6 +74,9 @@ export const TournamentDetailPage = (): JSX.Element => { } } if (tournament?.status === 'published') { + if (showInfoSidebar) { + return 'roster'; + } return 'info'; } return 'roster'; From 7262497f5a9ca604860bbd82b55c5506393d24ed Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 22:28:49 +0200 Subject: [PATCH 07/31] feat: Improve styling --- .../TournamentInfoBlock.module.scss | 11 ++++++++--- .../TournamentInfoBlock/TournamentInfoBlock.tsx | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss b/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss index 47eaba39..2a97ac8a 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss @@ -16,6 +16,7 @@ @include flex.row($yAlign: top, $gap: 0.5rem); @include text.ui; + overflow: hidden; min-width: 0; a { @@ -23,11 +24,15 @@ line-height: inherit; } + &_Content { + min-width: 0; + } + span { - overflow: hidden; + @include text.single-line; + display: block; - text-overflow: ellipsis; - text-wrap: nowrap; + min-width: 0; } svg { diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx index 9c791df5..404b4be2 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx @@ -39,14 +39,14 @@ export const TournamentInfoBlock = ({ <>
-
+
{format(tournament.startsAt, 'd MMM yyy, p')} {format(tournament.endsAt, 'd MMM yyyy, p')}
-
+
{tournament.location.name} {tournament.location.placeFormatted}
From 44c9e3bfb310528c846da87ca2b017f01f264052 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 22:29:02 +0200 Subject: [PATCH 08/31] Update mockData.ts --- convex/_model/utils/_helpers/mockData.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/convex/_model/utils/_helpers/mockData.ts b/convex/_model/utils/_helpers/mockData.ts index 880e9c22..2788338d 100644 --- a/convex/_model/utils/_helpers/mockData.ts +++ b/convex/_model/utils/_helpers/mockData.ts @@ -8,14 +8,17 @@ export const mockTournamentData = { // logoStorageId: v.optional(v.union(v.id('_storage'), v.null())), // bannerStorageId: v.optional(v.union(v.id('_storage'), v.null())), location: { - timeZone: '', - name: '', - placeFormatted: '', - coordinates: { - lat: 0, - lon: 0, - }, - countryCode: 'nl', + address: '', + city: 'Ann Arbor', + coordinates: { lat: 42.27859, lon: -83.739716 }, + countryCode: 'us', + district: 'Washtenaw County', + mapboxId: 'dXJuOm1ieHBsYzpKOTFNN0E', + name: 'University of Michigan', + placeFormatted: 'Ann Arbor, Michigan, United States', + postcode: '48109', + region: { code: 'mi', name: 'Michigan' }, + timeZone: 'America/Detroit', }, startsAt: '2025-06-21T09:00:00', endsAt:'2025-06-22T18:00:00', From c83a4e49b7dfcbb12a869d702a2718a316782036 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 25 Jun 2025 14:02:47 +0200 Subject: [PATCH 09/31] feat: Improve disabled state (#97) #94 --- .../generic/Accordion/AccordionItem.module.scss | 12 ++++++++---- .../generic/Accordion/AccordionItem.tsx | 17 ++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/generic/Accordion/AccordionItem.module.scss b/src/components/generic/Accordion/AccordionItem.module.scss index 973e6062..10256714 100644 --- a/src/components/generic/Accordion/AccordionItem.module.scss +++ b/src/components/generic/Accordion/AccordionItem.module.scss @@ -13,17 +13,21 @@ overflow: hidden; &_Header { - @include flex.row($gap: 1rem); - @include variants.ghost; - - cursor: pointer; padding: calc(1rem - var(--border-width)); + &[data-enabled="true"] { + @include variants.ghost; + + cursor: pointer; + } + &_Chevron { place-self: center; width: 1.5rem; height: 1.5rem; } + + @include flex.row($gap: 1rem); } &_Content { diff --git a/src/components/generic/Accordion/AccordionItem.tsx b/src/components/generic/Accordion/AccordionItem.tsx index 5b874093..01ea648a 100644 --- a/src/components/generic/Accordion/AccordionItem.tsx +++ b/src/components/generic/Accordion/AccordionItem.tsx @@ -41,15 +41,18 @@ export const AccordionItem = ({
- - - + {!disabled && ( + + + + )} {header}
From 705a8540917777c6d57cce4fec40c17bd4537021 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 25 Jun 2025 14:03:41 +0200 Subject: [PATCH 10/31] feat: Hide completed pairings from match check-in (#96) #95 --- .../FowV4MatchResultForm/FowV4MatchResultForm.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx index 28fc20ed..8bffffbc 100644 --- a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx +++ b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx @@ -2,7 +2,11 @@ import { SubmitHandler, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import clsx from 'clsx'; -import { MatchResultId, TournamentPairingId } from '~/api'; +import { + MatchResultId, + TournamentPairingId, + UserId, +} from '~/api'; import { useAuth } from '~/components/AuthProvider'; import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; import { FowV4MatchResultDetails } from '~/components/FowV4MatchResultDetails'; @@ -119,7 +123,7 @@ export const FowV4MatchResultForm = ({ const resultForOptions = [ { value: 'single', label: 'Single Match' }, - ...(tournamentPairings || []).map((pairing) => ({ + ...(tournamentPairings || []).filter((pairing) => pairing.matchResultsProgress.submitted < pairing.matchResultsProgress.required).map((pairing) => ({ value: pairing._id, label: getTournamentPairingDisplayName(pairing), })), @@ -128,6 +132,10 @@ export const FowV4MatchResultForm = ({ const handleChangeResultFor = (value?: SelectValue): void => { if (value) { setTournamentPairingId(value as TournamentPairingId); + if (user && value === 'single') { + form.setValue('player0UserId', user._id); + form.setValue('player1UserId', '' as UserId); + } } }; From 62d1e9599a1d68d44011995b4032a1cff97cb5c6 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 25 Jun 2025 14:29:33 +0200 Subject: [PATCH 11/31] bug: Preserve internal state (#98) #93 --- .../PairingsGridRow/PairingsGridRow.tsx | 3 +- .../PairingsGridRow/PairingsGridRow.utils.ts | 2 +- .../TournamentPairingsGrid.tsx | 100 +++++++++++++----- .../TournamentPairingsGrid.types.ts | 4 +- .../TournamentPairingsGrid.utils.ts | 32 +++--- .../TournamentPairingsGrid/index.ts | 7 +- .../ConfirmPairingsDialog.tsx | 3 +- .../components/PairingsStep/PairingsStep.tsx | 22 ++-- .../PairingsStep/PairingsStep.utils.ts | 41 +++++-- 9 files changed, 149 insertions(+), 65 deletions(-) diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx index 0f3909c5..59f375bd 100644 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx +++ b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx @@ -1,10 +1,11 @@ import { AnimatePresence, motion } from 'framer-motion'; import { CircleCheck, CircleX } from 'lucide-react'; -import { DraftTournamentPairing, TournamentCompetitorRanked } from '~/api'; +import { TournamentCompetitorRanked } from '~/api'; import { Draggable } from '../Draggable'; import { Droppable } from '../Droppable/Droppable'; import { PairableCompetitorCard } from '../PairableCompetitorCard'; +import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; import { checkDraftPairingIsValid } from './PairingsGridRow.utils'; import styles from './PairingsGridRow.module.scss'; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts index cd377314..a13691d4 100644 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts +++ b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts @@ -1,4 +1,4 @@ -import { DraftTournamentPairing } from '~/api'; +import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; /** * Checks if a DraftPairing is valid. diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx index 92e6588e..6d8b840b 100644 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx +++ b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx @@ -1,5 +1,7 @@ import { + forwardRef, useEffect, + useImperativeHandle, useMemo, useState, } from 'react'; @@ -11,20 +13,22 @@ import { rectIntersection, } from '@dnd-kit/core'; import { restrictToWindowEdges } from '@dnd-kit/modifiers'; +import isEqual from 'fast-deep-equal'; import { AnimatePresence } from 'framer-motion'; -import { DraftTournamentPairing, TournamentCompetitorId } from '~/api'; +import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; import { Label } from '~/components/generic/Label'; -import { - buildGridState, - buildPairingResult, - convertPairingResultToCompetitorList, -} from '~/components/TournamentPairingsGrid/TournamentPairingsGrid.utils'; import { useTournament } from '~/components/TournamentProvider'; import { Draggable } from './Draggable'; import { Droppable } from './Droppable'; import { PairableCompetitorCard } from './PairableCompetitorCard'; import { PairingsGridRow } from './PairingsGridRow'; +import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; +import { + buildGridState, + buildPairingResult, + convertPairingResultToCompetitorList, +} from './TournamentPairingsGrid.utils'; import styles from './TournamentPairingsGrid.module.scss'; @@ -45,22 +49,50 @@ const grabAnimationProps = { }; export interface TournamentPairingsGridProps { - value?: DraftTournamentPairing[]; + defaultValue?: DraftTournamentPairing[]; onChange: (value: DraftTournamentPairing[]) => void; } -export const TournamentPairingsGrid = ({ - value, +export interface TournamentPairingsGridHandle { + reset: (pairings: DraftTournamentPairing[]) => void; + isDirty: boolean; +} + +export const TournamentPairingsGrid = forwardRef(({ + defaultValue, onChange, -}: TournamentPairingsGridProps): JSX.Element => { +}: TournamentPairingsGridProps, ref): JSX.Element => { const tournament = useTournament(); const pairingIndexes = Array.from({ length: Math.ceil(tournament.maxCompetitors / 2) }, (_, i) => i); - // Store competitors with their opponentIds so we can check pairing validity - const competitors = useMemo(() => convertPairingResultToCompetitorList(value), [value]); - const state = useMemo(() => buildGridState(value), [value]); + // Store competitors with their opponentIds so we can check pairing validity: + const competitors = useMemo(() => convertPairingResultToCompetitorList(defaultValue), [defaultValue]); + // State: const [activeCompetitorId, setActiveCompetitorId] = useState(null); + const [gridState, setGridState] = useState(null); + + // Set internal state from parent: + useEffect(() => { + if (defaultValue && !gridState) { + setGridState(buildGridState(defaultValue)); + } + }, [defaultValue, gridState]); + + const pairingResult = useMemo(() => buildPairingResult(competitors, gridState), [competitors, gridState]); + const isDirty = !isEqual(defaultValue, pairingResult); + + // Emit change to parent components: + useEffect(() => { + onChange(pairingResult); + }, [pairingResult, onChange]); + + // Allow parent to reset and track dirty state: + useImperativeHandle(ref, () => ({ + reset: (pairings: DraftTournamentPairing[]): void => setGridState(buildGridState(pairings)), + pairingResult, + isDirty, + })); useEffect(() => { document.body.style.cursor = activeCompetitorId ? 'grabbing' : 'default'; @@ -76,28 +108,36 @@ export const TournamentPairingsGrid = ({ }; const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (!over) { + if (!over || !gridState) { return; } setActiveCompetitorId(null); - const updatedInternalState = Object.entries(state).map(([pairingCompetitorId, slotId]) => { - if (pairingCompetitorId === active.id) { - return [pairingCompetitorId, over.id]; + setGridState(Object.entries(gridState).map(([competitorId, slotId]) => { + + // If this ID is the active one, we're dragging it. Set it's slotID to 'over': + if (competitorId === active.id) { + return [competitorId, over.id]; } + + // If this slot is the target, move its competitor to 'unpaired': if (slotId === over.id) { - return [pairingCompetitorId, 'unpaired']; + return [competitorId, 'unpaired']; } - return [pairingCompetitorId, slotId]; + + // Otherwise do nothing: + return [competitorId, slotId]; }).reduce((acc, [pairingCompetitorId, slotId]) => ({ ...acc, [pairingCompetitorId as TournamentCompetitorId]: slotId, - }), {}); - onChange(buildPairingResult(competitors, updatedInternalState)); + }), {})); }; - const fullPairings = (value || []).filter((pairing) => pairing[0] && pairing[1]); - const unpairedCompetitors = (value || []).filter((pairing) => !!pairing[0] && pairing[1] === null).map((pairing) => pairing[0]); + const unpairedCompetitors = competitors.filter((c) => gridState && gridState[c.id] === 'unpaired'); const activeCompetitor = competitors.find((c) => c.id === activeCompetitorId); + const gridStatePivoted = Object.entries(gridState ?? {}).reduce((acc, [competitorId, slotId]) => ({ + ...acc, + [slotId]: competitors.find((c) => c.id === competitorId), + }), {} as Record); return (
- {pairingIndexes.map((i) => ( - - ))} + {pairingIndexes.map((i) => { + const pairing: DraftTournamentPairing = [ + gridStatePivoted[`${i}_0`] ?? null, + gridStatePivoted[`${i}_1`] ?? null, + ]; + return ( + + ); + })}
@@ -148,4 +194,4 @@ export const TournamentPairingsGrid = ({ ); -}; +}); diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts index 3e519e91..daf214a1 100644 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts +++ b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts @@ -1,3 +1,5 @@ -import { TournamentCompetitorId } from '~/api'; +import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; export type PairingsGridState = Record; + +export type DraftTournamentPairing = [TournamentCompetitorRanked | null, TournamentCompetitorRanked | null]; diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts index 2920f630..4baa4169 100644 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts +++ b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts @@ -1,8 +1,5 @@ -import { - DraftTournamentPairing, - TournamentCompetitorId, - TournamentCompetitorRanked, -} from '~/api'; +import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; +import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; export const convertPairingResultToCompetitorList = (draftPairings?: DraftTournamentPairing[]): TournamentCompetitorRanked[] => { if (!draftPairings) { @@ -25,22 +22,31 @@ export const buildGridState = (draftPairings?: DraftTournamentPairing[]): Record return {}; } return draftPairings.reduce((acc, pairing, i) => { - if (!pairing[1]) { + if (pairing[0] && !pairing[1]) { return { ...acc, [pairing[0].id]: 'unpaired', }; } - return { - ...acc, - [pairing[0].id]: `${i}_0`, - [pairing[1].id]: `${i}_1`, - }; + if (!pairing[0] && pairing[1]) { + return { + ...acc, + [pairing[1].id]: 'unpaired', + }; + } + if (pairing[0] && pairing[1]) { + return { + ...acc, + [pairing[0].id]: `${i}_0`, + [pairing[1].id]: `${i}_1`, + }; + } + return acc; }, {} as Record); }; -export const buildPairingResult = (competitors: TournamentCompetitorRanked[], state: Record): DraftTournamentPairing[] => { - if (!competitors?.length || !Object.keys(state).length) { +export const buildPairingResult = (competitors: TournamentCompetitorRanked[], state: PairingsGridState | null): DraftTournamentPairing[] => { + if (!competitors?.length || !state || !Object.keys(state).length) { return []; } const statefulCompetitors = competitors.map((competitor) => ({ diff --git a/src/components/TournamentPairingsGrid/index.ts b/src/components/TournamentPairingsGrid/index.ts index 954c5aee..0a628319 100644 --- a/src/components/TournamentPairingsGrid/index.ts +++ b/src/components/TournamentPairingsGrid/index.ts @@ -1 +1,6 @@ -export { TournamentPairingsGrid } from './TournamentPairingsGrid'; +export { + TournamentPairingsGrid, + type TournamentPairingsGridHandle, + type TournamentPairingsGridProps, +} from './TournamentPairingsGrid'; +export type { DraftTournamentPairing } from './TournamentPairingsGrid.types'; diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx index 705d68cd..e2b93281 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx +++ b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx @@ -1,6 +1,7 @@ -import { DraftTournamentPairing, UnassignedTournamentPairing } from '~/api'; +import { UnassignedTournamentPairing } from '~/api'; import { ConfirmationDialog } from '~/components/ConfirmationDialog'; import { TournamentPairingRow } from '~/components/TournamentPairingRow'; +import { DraftTournamentPairing } from '~/components/TournamentPairingsGrid'; import { convertDraftPairingsToUnassignedPairings } from '../PairingsStep/PairingsStep.utils'; import styles from './ConfirmPairingsDialog.module.scss'; diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx index a63bbfa5..3e02f25b 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx +++ b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx @@ -2,12 +2,11 @@ import { forwardRef, useEffect, useImperativeHandle, + useRef, useState, } from 'react'; -import isEqual from 'fast-deep-equal'; import { - DraftTournamentPairing, TournamentPairingMethod, tournamentPairingMethodOptions, UnassignedTournamentPairing, @@ -17,7 +16,11 @@ import { Button } from '~/components/generic/Button'; import { InputSelect } from '~/components/generic/InputSelect'; import { Label } from '~/components/generic/Label'; import { Separator } from '~/components/generic/Separator'; -import { TournamentPairingsGrid } from '~/components/TournamentPairingsGrid'; +import { + DraftTournamentPairing, + TournamentPairingsGrid, + TournamentPairingsGridHandle, +} from '~/components/TournamentPairingsGrid'; import { useTournament } from '~/components/TournamentProvider'; import { useGetDraftTournamentPairings } from '~/services/tournamentPairings'; import { ConfirmPairingsDialog, confirmPairingsDialogId } from '../ConfirmPairingsDialog'; @@ -39,7 +42,7 @@ export interface PairingsStepHandle { export const PairingsStep = forwardRef(({ nextRound, onConfirm, -}: PairingsStepProps, ref) => { +}: PairingsStepProps, ref): JSX.Element => { const tournament = useTournament(); // Pairing state @@ -51,14 +54,15 @@ export const PairingsStep = forwardRef(({ round: nextRound, method: pairingMethod, }); - const [manualPairings, setManualPairings] = useState(draftPairingResults); + const [manualPairings, setManualPairings] = useState(); useEffect(() => { if (draftPairingResults) { setManualPairings(draftPairingResults); } }, [draftPairingResults]); - const isDirty = manualPairings && !isEqual(manualPairings, draftPairingResults); + const pairingsGridRef = useRef(null); + const isDirty = pairingsGridRef.current?.isDirty ?? false; const { open: openChangePairingMethodConfirmDialog } = useConfirmationDialog(changePairingMethodConfirmDialogId); const { open: openResetPairingsConfirmDialog } = useConfirmationDialog(resetPairingsConfirmDialogId); @@ -78,7 +82,7 @@ export const PairingsStep = forwardRef(({ if (draftPairingResults) { if (isDirty) { openResetPairingsConfirmDialog({ - onConfirm: () => setManualPairings(draftPairingResults), + onConfirm: () => pairingsGridRef.current?.reset(draftPairingResults), }); } else { setManualPairings(draftPairingResults); @@ -102,12 +106,12 @@ export const PairingsStep = forwardRef(({ value={pairingMethod} disabled={isFirstRound} /> -
- + [ - ...draftTournamentPairings.map((tournamentPairing) => ({ - tournamentCompetitor0Id: tournamentPairing[0].id, - tournamentCompetitor1Id: tournamentPairing[1]?.id ?? null, - playedTables: Array.from(new Set([ - ...tournamentPairing[0].playedTables, - ...(tournamentPairing[1]?.playedTables || []), - ])), - })), -]; +): UnassignedTournamentPairing[] => draftTournamentPairings.filter(([a, b]) => a || b).map(([a, b]) => { + const playedTables = Array.from( + new Set([ + ...(a?.playedTables ?? []), + ...(b?.playedTables ?? []), + ]), + ); + if (a && !b) { + return { + tournamentCompetitor0Id: a.id, + tournamentCompetitor1Id: null, + playedTables, + }; + } + if (!a && b) { + return { + tournamentCompetitor0Id: b.id, + tournamentCompetitor1Id: null, + playedTables, + }; + } + // We've filtered out pairings with no competitors, and handled the one-sided ones above: + return { + tournamentCompetitor0Id: a!.id, + tournamentCompetitor1Id: b!.id, + playedTables, + }; +}); From e2aa511a2e097ce0433ae2231d3c91a60c70d46c Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 16:50:13 +0200 Subject: [PATCH 12/31] feat: #101 Add player count to roster (#103) --- .../TournamentRoster.module.scss | 23 +++++++++++----- .../TournamentRoster/TournamentRoster.tsx | 10 ++++--- .../CompetitorActions.module.scss | 2 +- .../CompetitorActions/CompetitorActions.tsx | 5 +++- .../PlayerCount/PlayerCount.module.scss | 12 +++++++++ .../components/PlayerCount/PlayerCount.tsx | 26 +++++++++++++++++++ .../components/PlayerCount/index.ts | 2 ++ 7 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss create mode 100644 src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx create mode 100644 src/components/TournamentRoster/components/PlayerCount/index.ts diff --git a/src/components/TournamentRoster/TournamentRoster.module.scss b/src/components/TournamentRoster/TournamentRoster.module.scss index fc2038af..548343ef 100644 --- a/src/components/TournamentRoster/TournamentRoster.module.scss +++ b/src/components/TournamentRoster/TournamentRoster.module.scss @@ -8,14 +8,25 @@ .TournamentRoster { &_Header { - @include flex.row; - @include flex.stretchy; + display: grid; + grid-template-areas: "Identity PlayerCount Actions"; + grid-template-columns: 1fr auto auto; + grid-template-rows: 2.5rem; + gap: 1rem; - &_Actions { - @include flex.row; + width: 100%; + } + + &_Identity { + grid-area: Identity; + } + + &_PlayerCount { + grid-area: PlayerCount; + } - margin-left: auto; - } + &_Actions { + grid-area: Actions; } &_Content { diff --git a/src/components/TournamentRoster/TournamentRoster.tsx b/src/components/TournamentRoster/TournamentRoster.tsx index e6156db9..5f2256c3 100644 --- a/src/components/TournamentRoster/TournamentRoster.tsx +++ b/src/components/TournamentRoster/TournamentRoster.tsx @@ -4,6 +4,7 @@ import { TournamentCompetitorEditDialog } from '~/components/TournamentCompetito import { useTournamentCompetitors } from '~/components/TournamentCompetitorsProvider'; import { useTournament } from '~/components/TournamentProvider'; import { CompetitorActions } from '~/components/TournamentRoster/components/CompetitorActions'; +import { PlayerCount } from '~/components/TournamentRoster/components/PlayerCount'; import styles from './TournamentRoster.module.scss'; @@ -14,7 +15,7 @@ export interface TournamentRosterProps { export const TournamentRoster = ({ className, }: TournamentRosterProps): JSX.Element => { - const { useTeams } = useTournament(); + const { useTeams, competitorSize } = useTournament(); const competitors = useTournamentCompetitors(); return ( <> @@ -22,8 +23,11 @@ export const TournamentRoster = ({ {(competitors || []).map((competitor) => (
- - + + {useTeams && ( + + )} +
{competitor.players.filter((player) => player.active).map((player) => ( diff --git a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss index 09e4aabc..4efbf490 100644 --- a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss +++ b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss @@ -9,5 +9,5 @@ .CompetitorActions { @include flex.row; - margin-left: auto; + flex-shrink: 0; } diff --git a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx index 25137975..2f066843 100644 --- a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx +++ b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx @@ -1,4 +1,5 @@ import { MouseEvent } from 'react'; +import clsx from 'clsx'; import { Ellipsis, UserPlus } from 'lucide-react'; import { TournamentCompetitor } from '~/api'; @@ -20,10 +21,12 @@ import { import styles from './CompetitorActions.module.scss'; export interface CompetitorActionsProps { + className?: string; competitor: TournamentCompetitor; } export const CompetitorActions = ({ + className, competitor, }: CompetitorActionsProps): JSX.Element => { const user = useAuth(); @@ -94,7 +97,7 @@ export const CompetitorActions = ({ return ( <> -
+
{showCheckInToggle && ( <> diff --git a/src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss new file mode 100644 index 00000000..e6c4b647 --- /dev/null +++ b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss @@ -0,0 +1,12 @@ +@use "/src/style/flex"; +@use "/src/style/text"; + +.PlayerCount { + @include flex.row($gap: 0.25rem); + @include text.ui($muted: true); + + svg { + width: 1rem; + height: 1rem; + } +} diff --git a/src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx new file mode 100644 index 00000000..c5258d6c --- /dev/null +++ b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx @@ -0,0 +1,26 @@ +import clsx from 'clsx'; +import { Users } from 'lucide-react'; + +import { TournamentCompetitor } from '~/api'; + +import styles from './PlayerCount.module.scss'; + +export interface PlayerCountProps { + className?: string; + competitor: TournamentCompetitor; + competitorSize: number; +} + +export const PlayerCount = ({ + className, + competitor, + competitorSize, +}: PlayerCountProps): JSX.Element => { + const playerCount = (competitor.players ?? []).filter((p) => p.active).length; + return ( +
+ + {`${playerCount}/${competitorSize}`} +
+ ); +}; diff --git a/src/components/TournamentRoster/components/PlayerCount/index.ts b/src/components/TournamentRoster/components/PlayerCount/index.ts new file mode 100644 index 00000000..6e47b7b8 --- /dev/null +++ b/src/components/TournamentRoster/components/PlayerCount/index.ts @@ -0,0 +1,2 @@ +export type { PlayerCountProps } from './PlayerCount'; +export { PlayerCount } from './PlayerCount'; From 164229baa430599a9790e6b8ff4b9671678abb61 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 21:46:57 +0200 Subject: [PATCH 13/31] feat: Show full player names when tournaments require it --- convex/_generated/api.d.ts | 2 ++ .../_helpers/checkUserTournamentForcedName.ts | 30 +++++++++++++++++++ convex/_model/users/_helpers/redactUser.ts | 7 ++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 convex/_model/users/_helpers/checkUserTournamentForcedName.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 11df2d20..35ab2830 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -126,6 +126,7 @@ import type * as _model_tournaments_queries_getTournamentRankings from "../_mode import type * as _model_tournaments_queries_getTournaments from "../_model/tournaments/queries/getTournaments.js"; import type * as _model_tournaments_queries_getTournamentsByStatus from "../_model/tournaments/queries/getTournamentsByStatus.js"; import type * as _model_users__helpers_checkUserAuth from "../_model/users/_helpers/checkUserAuth.js"; +import type * as _model_users__helpers_checkUserTournamentForcedName from "../_model/users/_helpers/checkUserTournamentForcedName.js"; import type * as _model_users__helpers_checkUserTournamentRelationship from "../_model/users/_helpers/checkUserTournamentRelationship.js"; import type * as _model_users__helpers_getShallowUser from "../_model/users/_helpers/getShallowUser.js"; import type * as _model_users__helpers_redactUser from "../_model/users/_helpers/redactUser.js"; @@ -308,6 +309,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/queries/getTournaments": typeof _model_tournaments_queries_getTournaments; "_model/tournaments/queries/getTournamentsByStatus": typeof _model_tournaments_queries_getTournamentsByStatus; "_model/users/_helpers/checkUserAuth": typeof _model_users__helpers_checkUserAuth; + "_model/users/_helpers/checkUserTournamentForcedName": typeof _model_users__helpers_checkUserTournamentForcedName; "_model/users/_helpers/checkUserTournamentRelationship": typeof _model_users__helpers_checkUserTournamentRelationship; "_model/users/_helpers/getShallowUser": typeof _model_users__helpers_getShallowUser; "_model/users/_helpers/redactUser": typeof _model_users__helpers_redactUser; diff --git a/convex/_model/users/_helpers/checkUserTournamentForcedName.ts b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts new file mode 100644 index 00000000..5a0a62be --- /dev/null +++ b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts @@ -0,0 +1,30 @@ +import { Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getTournamentUserIds } from '../../../_model/tournaments'; + +export const checkUserTournamentForcedName = async ( + ctx: QueryCtx, + userIdA?: Id<'users'> | null, + userIdB?: Id<'users'> | null, +): Promise => { + if (!userIdA || !userIdB) { + return false; + } + + const tournaments = await ctx.db.query('tournaments').collect(); + + // Check each tournament for a relationship, return true if one is found + return tournaments.some(async ({ _id, organizerUserIds, requireRealNames }) => { + + const playerUserIds = await getTournamentUserIds(ctx, _id); + + // Merge all organizer IDs and player IDs into one set + const allTournamentUserIds = new Set([ + ...organizerUserIds, + ...playerUserIds, + ]); + + // If the set contains both user IDs, they were at the same tournament + return allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB) && requireRealNames; + }); +}; diff --git a/convex/_model/users/_helpers/redactUser.ts b/convex/_model/users/_helpers/redactUser.ts index d3e001ef..961820b9 100644 --- a/convex/_model/users/_helpers/redactUser.ts +++ b/convex/_model/users/_helpers/redactUser.ts @@ -3,6 +3,7 @@ import { getAuthUserId } from '@convex-dev/auth/server'; import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; +import { checkUserTournamentForcedName } from './checkUserTournamentForcedName'; import { checkUserTournamentRelationship } from './checkUserTournamentRelationship'; /** @@ -51,11 +52,15 @@ export const redactUser = async ( // If user is querying someone they are in a tournament with const hasTournamentRelationship = await checkUserTournamentRelationship(ctx, userId, user._id); + // If user is querying someone they are in a tournament with which requires real names + const requiredByTournament = await checkUserTournamentForcedName(ctx, userId, user._id); + // Add name information if allowed if ( (user?.nameVisibility === 'public') || (user?.nameVisibility === 'friends' && hasFriendRelationship) || - (user?.nameVisibility === 'tournaments' && (hasFriendRelationship || hasTournamentRelationship)) + (user?.nameVisibility === 'tournaments' && (hasFriendRelationship || hasTournamentRelationship)) || + requiredByTournament ) { limitedUser.givenName = user.givenName; limitedUser.familyName = user.familyName; From 8caccb1b4ac6b1ae7a01ea3538943647133e63f4 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 21:47:21 +0200 Subject: [PATCH 14/31] feat: Add activePlayerCount to deep tournaments --- convex/_model/tournaments/_helpers/deepenTournament.ts | 2 ++ src/components/TournamentInfoBlock/TournamentInfoBlock.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index d9ff7f74..e58a7098 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -37,6 +37,7 @@ export const deepenTournament = async ( // Computed properties (easy to do, but used so frequently, it's nice to include them by default) const playerCount = playerUserIds.length; + const activePlayerCount = activePlayerUserIds.length; const maxPlayers = tournament.maxCompetitors * tournament.competitorSize; const useTeams = tournament.competitorSize > 1; @@ -45,6 +46,7 @@ export const deepenTournament = async ( logoUrl, bannerUrl, competitorCount, + activePlayerCount, playerCount, playerUserIds, activePlayerUserIds, diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx index 404b4be2..f1d34603 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx @@ -53,7 +53,7 @@ export const TournamentInfoBlock = ({
- {`${tournament.playerCount} / ${tournament.maxPlayers}`} + {`${tournament.activePlayerCount} / ${tournament.maxPlayers}`} {tournament.useTeams && ( {`(${tournament.competitorCount} / ${tournament.maxCompetitors} teams)`} )} From 6a6d187154f203546c15b6da378c705a41f514ec Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 21:47:42 +0200 Subject: [PATCH 15/31] feat: Sort tournament competitors by name --- .../queries/getTournamentCompetitors.ts | 17 ++++++++++++++++- .../getTournamentCompetitorsByTournament.ts | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts index 141db622..479846f8 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts @@ -5,7 +5,22 @@ export const getTournamentCompetitors = async ( ctx: QueryCtx, ): Promise => { const tournamentCompetitors = await ctx.db.query('tournamentCompetitors').collect(); - return await Promise.all(tournamentCompetitors.map( + const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( async (item) => await deepenTournamentCompetitor(ctx, item), )); + return deepTournamentCompetitors.sort((a, b) => { + const getSortValue = (competitor: DeepTournamentCompetitor): string => { + if (competitor.teamName) { + return competitor.teamName; + } + if (competitor.players[0].user.familyName) { + return competitor.players[0].user.familyName; + } + if (competitor.players[0].user.username) { + return competitor.players[0].user.username; + } + return ''; + }; + return getSortValue(a).localeCompare(getSortValue(b)); + }); }; diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index c94ace79..fdc7fe50 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -14,7 +14,22 @@ export const getTournamentCompetitorsByTournament = async ( const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - return await Promise.all(tournamentCompetitors.map( + const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( async (item) => await deepenTournamentCompetitor(ctx, item), )); + return deepTournamentCompetitors.sort((a, b) => { + const getSortValue = (competitor: DeepTournamentCompetitor): string => { + if (competitor.teamName) { + return competitor.teamName; + } + if (competitor.players[0].user.familyName) { + return competitor.players[0].user.familyName; + } + if (competitor.players[0].user.username) { + return competitor.players[0].user.username; + } + return ''; + }; + return getSortValue(a).localeCompare(getSortValue(b)); + }); }; From 89594e9d8fbc0d87d044ee03465e43a21f51e232 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:02:45 +0200 Subject: [PATCH 16/31] task: Clean-up .card mixin (#102) #100 --- src/components/AccountMenu/AccountMenu.module.scss | 5 +---- .../AvatarEditable/AvatarEditable.module.scss | 5 +---- .../MatchResultCard/MatchResultCard.module.scss | 3 --- .../MatchResultPlayers/MatchResultPlayers.module.scss | 5 +---- .../ToastProvider/ToastProvider.module.scss | 3 +-- .../TournamentCard/TournamentCard.module.scss | 2 -- src/components/generic/Card/Card.module.scss | 3 --- .../generic/DataTable/DataTable.module.scss | 5 +---- .../generic/InputDateTime/InputDateTime.module.scss | 3 +-- .../generic/InputLocation/InputLocation.module.scss | 3 +-- .../generic/PopoverMenu/PopoverMenu.module.scss | 4 +--- src/pages/TournamentsPage/TournamentsPage.module.scss | 5 +---- src/style/_variants.scss | 11 +++++++++-- 13 files changed, 18 insertions(+), 39 deletions(-) diff --git a/src/components/AccountMenu/AccountMenu.module.scss b/src/components/AccountMenu/AccountMenu.module.scss index ed3c6f2e..dbe7af09 100644 --- a/src/components/AccountMenu/AccountMenu.module.scss +++ b/src/components/AccountMenu/AccountMenu.module.scss @@ -16,14 +16,11 @@ .Content { @include flex.column($gap: 0.25rem); + @include variants.card($elevated: true); @include animate.duration-quick; - @include corners.normal; - @include shadows.elevated; - @include variants.card; margin: 0.25rem 0; padding: 0.25rem; - background-color: var(--card-bg); @include animate.style-pop; // Must list last because it contains nested declarations } diff --git a/src/components/AvatarEditable/AvatarEditable.module.scss b/src/components/AvatarEditable/AvatarEditable.module.scss index 58bf7841..c05ad7ac 100644 --- a/src/components/AvatarEditable/AvatarEditable.module.scss +++ b/src/components/AvatarEditable/AvatarEditable.module.scss @@ -25,14 +25,11 @@ .ActionsMenu { @include flex.column($gap: 0.25rem); @include animate.duration-quick; - @include corners.normal; - @include shadows.elevated; - @include variants.card; + @include variants.card($elevated: true); z-index: 10000; margin: 0.25rem 0; padding: 0.25rem; - background-color: var(--card-bg); @include animate.style-pop; // Must list last because it contains nested declarations } diff --git a/src/components/MatchResultCard/MatchResultCard.module.scss b/src/components/MatchResultCard/MatchResultCard.module.scss index 93b2a24e..44f51d22 100644 --- a/src/components/MatchResultCard/MatchResultCard.module.scss +++ b/src/components/MatchResultCard/MatchResultCard.module.scss @@ -9,10 +9,7 @@ .MatchResultCard { @include flex.column($gap: 0); - @include variants.card; - @include shadows.surface; - @include corners.normal; position: relative; overflow: hidden; diff --git a/src/components/MatchResultPlayers/MatchResultPlayers.module.scss b/src/components/MatchResultPlayers/MatchResultPlayers.module.scss index f2268fae..7e32cacf 100644 --- a/src/components/MatchResultPlayers/MatchResultPlayers.module.scss +++ b/src/components/MatchResultPlayers/MatchResultPlayers.module.scss @@ -109,13 +109,10 @@ } .FactionDetails { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; @include flex.column($gap: 0); margin: 0.25rem 0; - background-color: var(--card-bg); - border-radius: variables.$corner-radius; } diff --git a/src/components/ToastProvider/ToastProvider.module.scss b/src/components/ToastProvider/ToastProvider.module.scss index 515e06a8..838f09b3 100644 --- a/src/components/ToastProvider/ToastProvider.module.scss +++ b/src/components/ToastProvider/ToastProvider.module.scss @@ -26,8 +26,7 @@ .Root { --icon-size: 1.25rem; - @include corners.wide; - @include variants.card; + @include variants.card($elevated: true); @include text.small; display: flex; diff --git a/src/components/TournamentCard/TournamentCard.module.scss b/src/components/TournamentCard/TournamentCard.module.scss index 154e3174..ef3f2706 100644 --- a/src/components/TournamentCard/TournamentCard.module.scss +++ b/src/components/TournamentCard/TournamentCard.module.scss @@ -12,8 +12,6 @@ --banner-wide-size: 14rem; @include variants.card; - @include shadows.surface; - @include corners.normal; @include text.ui; position: relative; diff --git a/src/components/generic/Card/Card.module.scss b/src/components/generic/Card/Card.module.scss index 06b28ac2..a38e3dd4 100644 --- a/src/components/generic/Card/Card.module.scss +++ b/src/components/generic/Card/Card.module.scss @@ -8,11 +8,8 @@ .Card { @include flex.column($gap: 0); @include variants.card; - @include shadows.surface; - @include corners.normal; @include flex.stretchy; min-height: 0; max-height: 100%; - background-color: var(--card-bg); } diff --git a/src/components/generic/DataTable/DataTable.module.scss b/src/components/generic/DataTable/DataTable.module.scss index 88c21621..bfd4bbf8 100644 --- a/src/components/generic/DataTable/DataTable.module.scss +++ b/src/components/generic/DataTable/DataTable.module.scss @@ -27,14 +27,11 @@ $row-height: 3rem; } .PopoverContent { - @include shadows.elevated; @include animate.duration-quick; - @include variants.card; + @include variants.card($elevated: true); margin: 0.25rem 0; padding: 1rem; - background-color: var(--card-bg); - border-radius: variables.$corner-radius; @include animate.style-pop; // Must list last because it contains nested declarations } diff --git a/src/components/generic/InputDateTime/InputDateTime.module.scss b/src/components/generic/InputDateTime/InputDateTime.module.scss index e8c25663..3d4c364e 100644 --- a/src/components/generic/InputDateTime/InputDateTime.module.scss +++ b/src/components/generic/InputDateTime/InputDateTime.module.scss @@ -20,8 +20,7 @@ } .Content { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; @include flex.column($gap: 0); diff --git a/src/components/generic/InputLocation/InputLocation.module.scss b/src/components/generic/InputLocation/InputLocation.module.scss index a88ed4cb..ef0e349b 100644 --- a/src/components/generic/InputLocation/InputLocation.module.scss +++ b/src/components/generic/InputLocation/InputLocation.module.scss @@ -6,8 +6,7 @@ @use "/src/style/flex"; .InputDatePopoverContent { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; diff --git a/src/components/generic/PopoverMenu/PopoverMenu.module.scss b/src/components/generic/PopoverMenu/PopoverMenu.module.scss index 7ecad3cd..862c84d1 100644 --- a/src/components/generic/PopoverMenu/PopoverMenu.module.scss +++ b/src/components/generic/PopoverMenu/PopoverMenu.module.scss @@ -10,9 +10,7 @@ .PopoverContent { @include flex.column($gap: 0.25rem); @include animate.duration-quick; - @include corners.normal; - @include shadows.elevated; - @include variants.card; + @include variants.card($elevated: true); z-index: 1; margin: 0.25rem 0; diff --git a/src/pages/TournamentsPage/TournamentsPage.module.scss b/src/pages/TournamentsPage/TournamentsPage.module.scss index 2c656ae7..64fe4150 100644 --- a/src/pages/TournamentsPage/TournamentsPage.module.scss +++ b/src/pages/TournamentsPage/TournamentsPage.module.scss @@ -11,15 +11,12 @@ } .FilterPopover { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; margin: 0.25rem 0; padding: 1rem; - background-color: var(--card-bg); - border-radius: variables.$corner-radius; } .List { diff --git a/src/style/_variants.scss b/src/style/_variants.scss index df383775..f840d409 100644 --- a/src/style/_variants.scss +++ b/src/style/_variants.scss @@ -1,5 +1,7 @@ @use "variables"; @use "/src/style/borders"; +@use "/src/style/corners"; +@use "/src/style/shadows"; // TODO: Move to utils @mixin reset { @@ -190,9 +192,14 @@ } } -@mixin card { +@mixin card($elevated: false) { @include borders.normal; + @include corners.normal; + @include shadows.surface; background-color: var(--card-bg); - border-color: var(--border-color-default); + + @if $elevated { + @include shadows.elevated; + } } From 06ecd9d423e404fb4d488b8b60b1fca3e10a024f Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:17:21 +0200 Subject: [PATCH 17/31] feat: #99 Improve tournament competitor edit dialog (#104) --- .../TournamentCompetitorEditDialog.tsx | 22 +++++---- .../TournamentCompetitorForm.module.scss | 4 ++ .../TournamentCompetitorForm.schema.ts | 45 ++++++++----------- .../TournamentCompetitorForm.tsx | 40 ++++++++++++++--- .../CompetitorActions/CompetitorActions.tsx | 6 +-- 5 files changed, 73 insertions(+), 44 deletions(-) diff --git a/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx b/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx index fd36e82a..e7870637 100644 --- a/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx +++ b/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx @@ -4,6 +4,7 @@ import { DialogActions, DialogHeader, } from '~/components/generic/Dialog'; +import { ScrollArea } from '~/components/generic/ScrollArea'; import { TournamentCompetitorForm, TournamentCompetitorSubmitData } from '~/components/TournamentCompetitorForm'; import { useUpdateTournamentCompetitor } from '~/services/tournamentCompetitors'; import { useTournamentCompetitorEditDialog } from './TournamentCompetitorEditDialog.hooks'; @@ -25,22 +26,27 @@ export const TournamentCompetitorEditDialog = (): JSX.Element => { if (!data) { return; } + + const { players, ...restData } = formData; updateTournamentCompetitor({ id: data?.tournamentCompetitor._id, - ...formData, + ...restData, + players: players.filter((player) => player.userId), }); }; return ( - + + + diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss index c5348d3d..f35e4bff 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss @@ -11,6 +11,10 @@ row-gap: 0.5rem; column-gap: 1rem; align-items: center; + + &_AddButton { + grid-column: 1/-1; + } } &_SinglePlayer { diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts index 299dc51d..31563df4 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts @@ -17,21 +17,19 @@ export const createSchema = ( ), }).superRefine((data, ctx) => { const activeCount = data.players.filter((player) => player.active).length; - if (status === 'active') { - if (activeCount < competitorSize) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `At least ${competitorSize} player(s) must be active.`, - path: ['players'], - }); - } - if (activeCount > competitorSize) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Only ${competitorSize} player(s) may be active.`, - path: ['players'], - }); - } + if (activeCount < competitorSize && status === 'active') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `At least ${competitorSize} players must be active.`, + path: ['players'], + }); + } + if (activeCount > competitorSize) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Only ${competitorSize} players may be active.`, + path: ['players'], + }); } if (otherCompetitors.find((c) => c.teamName?.toLowerCase() === data.teamName.trim().toLowerCase())) { ctx.addIssue({ @@ -49,17 +47,10 @@ export const defaultValues: DeepPartial = { players: [], }; -export const getDefaultValues = (competitorSize: number, existingCompetitor?: TournamentCompetitor): DeepPartial => { - const emptyPlayers = Array.from({ length: competitorSize }, () => ({ - active: true, - userId: '', - })); - const existingPlayers = (existingCompetitor?.players || []).map(({ active, user }) => ({ +export const getDefaultValues = (competitorSize: number, existingCompetitor?: TournamentCompetitor): DeepPartial => ({ + teamName: existingCompetitor?.teamName ?? '', + players: (existingCompetitor?.players || []).map(({ active, user }) => ({ active, userId: user._id, - })); - return { - teamName: existingCompetitor?.teamName ?? '', - players: existingPlayers.length ? existingPlayers : emptyPlayers, - }; -}; + })), +}); diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx index f28cc6b8..5506b273 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx @@ -1,3 +1,4 @@ +import { MouseEvent } from 'react'; import { Fragment } from 'react/jsx-runtime'; import { SubmitHandler, @@ -7,6 +8,7 @@ import { } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import clsx from 'clsx'; +import { Plus } from 'lucide-react'; import { TournamentCompetitor, @@ -14,6 +16,7 @@ import { UserId, } from '~/api'; import { useAuth } from '~/components/AuthProvider'; +import { Button } from '~/components/generic/Button'; import { Form, FormField } from '~/components/generic/Form'; import { InputSelect } from '~/components/generic/InputSelect'; import { InputText } from '~/components/generic/InputText'; @@ -70,7 +73,7 @@ export const TournamentCompetitorForm = ({ defaultValues: getDefaultValues(competitorSize, tournamentCompetitor), mode: 'onSubmit', }); - const { fields } = useFieldArray({ + const { fields, append } = useFieldArray({ control: form.control, name: 'players', rules: { @@ -91,11 +94,25 @@ export const TournamentCompetitorForm = ({ form.setValue(`players.${i}.userId`, userId); } }; + const handleChangePlayerActive = (i: number, active: boolean): void => { form.setValue(`players.${i}.active`, active); }; - const handleSubmit: SubmitHandler = async (formData): Promise => { - onSubmit({ tournamentId, ...formData }); + + const handleAddPlayer = (e: MouseEvent): void => { + e.preventDefault(); + append({ + active: players.filter((p) => p.active).length < competitorSize, + userId: '' as UserId, + }); + }; + + const handleSubmit: SubmitHandler = async ({ players, ...formData }): Promise => { + onSubmit({ + tournamentId, + players: players.filter((p) => p.userId.length), + ...formData, + }); }; const excludedUserIds = [ @@ -105,6 +122,8 @@ export const TournamentCompetitorForm = ({ const isOrganizer = user && organizerUserIds.includes(user._id); + const emptyPlayerSlots = players.filter((player) => !player.userId.length).length; + return ( {useTeams && ( @@ -124,20 +143,29 @@ export const TournamentCompetitorForm = ({ handleChangePlayerActive(i, value)} disabled={loading} /> handleChangeUser(i, value)} excludedUserIds={excludedUserIds} - disabled={loading} + disabled={loading || (!!players[i]?.userId && status !== 'published')} allowPlaceholder={false} /> ))} +
+ +
) : ( <> diff --git a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx index 2f066843..59ff582f 100644 --- a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx +++ b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx @@ -79,17 +79,17 @@ export const CompetitorActions = ({ playerUserId: user!._id, tournamentCompetitorId: competitor._id, }), - visible: user && isPlayer && tournament.status !== 'archived', + visible: isPlayer && !['active', 'archived'].includes(tournament.status), }, { label: 'Edit', onClick: () => openEditDialog({ tournamentCompetitor: competitor }), - visible: user && (isOrganizer || (isPlayer && tournament.useTeams)) && tournament.status !== 'archived' && tournament.currentRound === undefined, + visible: (isOrganizer || (isPlayer && tournament.useTeams)) && tournament.status !== 'archived' && tournament.currentRound === undefined, }, { label: 'Delete', onClick: () => openConfirmDeleteDialog(), - visible: user && isOrganizer && tournament.status === 'published', + visible: isOrganizer && tournament.status === 'published', }, ]; From 87452e40e9a4f9cd8d694714c5a80ffff7d46e5b Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:46:09 +0200 Subject: [PATCH 18/31] feat: #106 Improve signIn error handling (#107) --- .../components/SignInForm/SignInForm.schema.ts | 2 +- src/services/auth/useSignIn.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts index a80f008c..f43ba2a2 100644 --- a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts +++ b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const signInFormSchema = z.object({ - email: z.string().min(1, 'Please enter your email.').transform((val) => val.trim().toLowerCase()), + email: z.string().email('Please enter your email.').transform((val) => val.trim().toLowerCase()), password: z.string().min(1, 'Please enter your password.').transform((val) => val.trim()), }); diff --git a/src/services/auth/useSignIn.ts b/src/services/auth/useSignIn.ts index becc3372..335ac107 100644 --- a/src/services/auth/useSignIn.ts +++ b/src/services/auth/useSignIn.ts @@ -14,6 +14,19 @@ type SignInInput = { password: string; }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapConvexAuthError(error: any): string { + if (!error?.message) { + return 'An unexpected error occurred. Please try again.'; + } + + if (error.message.includes('InvalidAccountId')) { + return 'Your email or password is incorrect.'; + } + + return 'Sign-in failed. Please check your details and try again.'; +} + export const useSignIn = () => { const { signIn } = useAuthActions(); const navigate = useNavigate(); @@ -47,8 +60,9 @@ export const useSignIn = () => { flow: 'signIn', }).catch((error) => { setLoading(false); + const description = mapConvexAuthError(error); console.error(error); - toast.error('Error', { description: error.message }); + toast.error('Error', { description: description }); }); }, loading, From 5ded5b650656414f2f9356f661b02972898a2666 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:47:29 +0200 Subject: [PATCH 19/31] Update convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../queries/getTournamentCompetitorsByTournament.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index fdc7fe50..de9087c9 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -22,10 +22,10 @@ export const getTournamentCompetitorsByTournament = async ( if (competitor.teamName) { return competitor.teamName; } - if (competitor.players[0].user.familyName) { + if (competitor.players[0]?.user.familyName) { return competitor.players[0].user.familyName; } - if (competitor.players[0].user.username) { + if (competitor.players[0]?.user.username) { return competitor.players[0].user.username; } return ''; From 08df6c7ed66714cfd9194a7c8e89c3eddf70a255 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:51:48 +0200 Subject: [PATCH 20/31] Update convex/_model/users/_helpers/checkUserTournamentForcedName.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../users/_helpers/checkUserTournamentForcedName.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/convex/_model/users/_helpers/checkUserTournamentForcedName.ts b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts index 5a0a62be..423be595 100644 --- a/convex/_model/users/_helpers/checkUserTournamentForcedName.ts +++ b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts @@ -14,8 +14,8 @@ export const checkUserTournamentForcedName = async ( const tournaments = await ctx.db.query('tournaments').collect(); // Check each tournament for a relationship, return true if one is found - return tournaments.some(async ({ _id, organizerUserIds, requireRealNames }) => { - + // Check each tournament for a relationship, return true if one is found + for (const { _id, organizerUserIds, requireRealNames } of tournaments) { const playerUserIds = await getTournamentUserIds(ctx, _id); // Merge all organizer IDs and player IDs into one set @@ -25,6 +25,10 @@ export const checkUserTournamentForcedName = async ( ]); // If the set contains both user IDs, they were at the same tournament - return allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB) && requireRealNames; - }); + if (allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB) && requireRealNames) { + return true; + } + } + + return false; }; From 85245b1d3ae9d13040968d5672a697f9417bb7a6 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 1 Jul 2025 07:32:48 +0200 Subject: [PATCH 21/31] feat: Hide players with 0 matches from rankings --- convex/_model/fowV4/aggregateFowV4TournamentData.ts | 4 ++-- convex/_model/fowV4/flattenFowV4StatMap.ts | 3 ++- convex/_model/users/_helpers/redactUser.ts | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/convex/_model/fowV4/aggregateFowV4TournamentData.ts b/convex/_model/fowV4/aggregateFowV4TournamentData.ts index 1af355e7..b2e4c124 100644 --- a/convex/_model/fowV4/aggregateFowV4TournamentData.ts +++ b/convex/_model/fowV4/aggregateFowV4TournamentData.ts @@ -131,11 +131,11 @@ export const aggregateFowV4TournamentData = async ( } return { - players: flattenFowV4StatMap(playerStats).map(({ id, stats }) => ({ + players: flattenFowV4StatMap(playerStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ id, stats, })), - competitors: flattenFowV4StatMap(competitorStats).map(({ id, stats }) => ({ + competitors: flattenFowV4StatMap(competitorStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ id, stats, ...competitorMeta[id], diff --git a/convex/_model/fowV4/flattenFowV4StatMap.ts b/convex/_model/fowV4/flattenFowV4StatMap.ts index 818a3ed9..f546dbd6 100644 --- a/convex/_model/fowV4/flattenFowV4StatMap.ts +++ b/convex/_model/fowV4/flattenFowV4StatMap.ts @@ -13,7 +13,7 @@ import { export const flattenFowV4StatMap = ( statMap: Record, -): { id: T; stats: FowV4TournamentFlatExtendedStats }[] => { +): { id: T; stats: FowV4TournamentFlatExtendedStats, gamesPlayed: number }[] => { const statList = Object.entries(statMap) as [T, FowV4TournamentExtendedStats][]; return statList.map(([key, stats]) => { const flatStats = {} as FowV4TournamentFlatExtendedStats; @@ -27,6 +27,7 @@ export const flattenFowV4StatMap = ( return { id: key, stats: flatStats, + gamesPlayed: stats.gamesPlayed ?? 0, }; }); }; diff --git a/convex/_model/users/_helpers/redactUser.ts b/convex/_model/users/_helpers/redactUser.ts index b1b7320e..961820b9 100644 --- a/convex/_model/users/_helpers/redactUser.ts +++ b/convex/_model/users/_helpers/redactUser.ts @@ -52,7 +52,6 @@ export const redactUser = async ( // If user is querying someone they are in a tournament with const hasTournamentRelationship = await checkUserTournamentRelationship(ctx, userId, user._id); - // If user is querying someone they are in a tournament with which requires real names const requiredByTournament = await checkUserTournamentForcedName(ctx, userId, user._id); From e681f8d7a21921c6a089a9ebb718173d86b356d8 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 9 Jul 2025 07:20:53 +0200 Subject: [PATCH 22/31] feat: #112 Add more mercenary team options (#113) --- .vscode/settings.json | 1 + .../generic/FlagCircle/FlagCircle.module.scss | 31 + .../generic/FlagCircle/FlagCircle.scss | 17 - .../generic/FlagCircle/FlagCircle.tsx | 15 +- .../FlagCircle/customFlags/1x1/xx-lkt.svg | 638 +++++++++++++++++ .../FlagCircle/customFlags/1x1/xx-prt.svg | 77 +++ .../FlagCircle/customFlags/4x3/xx-lkt.svg | 640 ++++++++++++++++++ .../FlagCircle/customFlags/4x3/xx-prt.svg | 65 ++ src/utils/common/getCountryName.ts | 21 +- src/utils/common/getCountryOptions.ts | 7 +- 10 files changed, 1483 insertions(+), 29 deletions(-) create mode 100644 src/components/generic/FlagCircle/FlagCircle.module.scss delete mode 100644 src/components/generic/FlagCircle/FlagCircle.scss create mode 100644 src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg create mode 100644 src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg create mode 100644 src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg create mode 100644 src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg diff --git a/.vscode/settings.json b/.vscode/settings.json index 30fd9ac7..6e729704 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "figtree", "hookform", "Korps", + "Landsknechte", "LFTF", "Mapbox", "merc", diff --git a/src/components/generic/FlagCircle/FlagCircle.module.scss b/src/components/generic/FlagCircle/FlagCircle.module.scss new file mode 100644 index 00000000..08715622 --- /dev/null +++ b/src/components/generic/FlagCircle/FlagCircle.module.scss @@ -0,0 +1,31 @@ +@use "/src/style/variables"; + +.FlagCircle { + flex-shrink: 0; + + aspect-ratio: 1; + width: 100%; + + border-radius: 100%; + + // A normal border pushes the background image 1px away from the edges giving the circle "flat sides" + box-shadow: inset var(--border-color-default) 0 0 0 1px; +} + +.FlagCircleCustom { + background-color: transparent; + background-repeat: no-repeat; + background-size: cover; + + &[data-code="xx-lkt"] { + background-image: url("./customFlags/1x1/xx-lkt.svg"); + } + + &[data-code="xx-mrc"] { + background-color: black; + } + + &[data-code="xx-prt"] { + background-image: url("./customFlags/1x1/xx-prt.svg"); + } +} diff --git a/src/components/generic/FlagCircle/FlagCircle.scss b/src/components/generic/FlagCircle/FlagCircle.scss deleted file mode 100644 index d4dd77a2..00000000 --- a/src/components/generic/FlagCircle/FlagCircle.scss +++ /dev/null @@ -1,17 +0,0 @@ -@use "/src/style/variables"; - -.FlagCircle { - flex-shrink: 0; - - aspect-ratio: 1; - width: 100%; - - border-radius: 100%; - - // A normal border pushes the background image 1px away from the edges giving the circle "flat sides" - box-shadow: inset var(--border-color-default) 0 0 0 1px; -} - -.FlagCircle-merc { - background-color: yellow; -} diff --git a/src/components/generic/FlagCircle/FlagCircle.tsx b/src/components/generic/FlagCircle/FlagCircle.tsx index 45022d3c..bed5aef5 100644 --- a/src/components/generic/FlagCircle/FlagCircle.tsx +++ b/src/components/generic/FlagCircle/FlagCircle.tsx @@ -1,7 +1,13 @@ import clsx from 'clsx'; import '/node_modules/flag-icons/css/flag-icons.min.css'; -import './FlagCircle.scss'; +import styles from './FlagCircle.module.scss'; + +const customCodes = [ + 'xx-lkt', + 'xx-mrc', + 'xx-prt', +]; export interface FlagCircleProps { className?: string; @@ -12,16 +18,17 @@ export const FlagCircle = ({ className, code, }: FlagCircleProps): JSX.Element => { - if (code === 'merc') { + if (customCodes.includes(code)) { return (
); } return (
); }; diff --git a/src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg b/src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg new file mode 100644 index 00000000..d44c9823 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg @@ -0,0 +1,638 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg b/src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg new file mode 100644 index 00000000..200f0b45 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg b/src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg new file mode 100644 index 00000000..810d4301 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg b/src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg new file mode 100644 index 00000000..d4b76814 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/utils/common/getCountryName.ts b/src/utils/common/getCountryName.ts index e8c65463..7c8d67b9 100644 --- a/src/utils/common/getCountryName.ts +++ b/src/utils/common/getCountryName.ts @@ -1,13 +1,20 @@ import { country, subdivision } from 'iso-3166-2'; export const getCountryName = (code: string): string | undefined => { - if (code === 'merc') { + if (code === 'xx-lkt') { + return 'Landsknechte'; + } + if (code === 'xx-mrc') { return 'Mercenaries'; - } else { - if (code.includes('-')) { - return subdivision(code)?.name; - } else { - return country(code)?.name; - } } + if (code === 'xx-prt') { + return 'Pirates'; + } + if (code === 'un') { + return 'United Nations'; + } + if (code.includes('-')) { + return subdivision(code)?.name; + } + return country(code)?.name; }; diff --git a/src/utils/common/getCountryOptions.ts b/src/utils/common/getCountryOptions.ts index f3680e57..9bd9c0a8 100644 --- a/src/utils/common/getCountryOptions.ts +++ b/src/utils/common/getCountryOptions.ts @@ -39,7 +39,6 @@ export const getEtcCountryOptions = (): InputSelectOption[] => [ 'ie', 'is', 'it', - 'merc', 'nl', 'nz', 'pl', @@ -47,6 +46,12 @@ export const getEtcCountryOptions = (): InputSelectOption[] => [ 'ro', 'se', 'us', + + // Mercenary teams + 'xx-lkt', // Landsknecht + 'xx-mrc', // Mercenaries + 'xx-prt', // Pirates + 'un', // United Nations ].map( (code) => ({ label: getCountryName(code) || 'Unknown Country', value: code }), ).sort( From 24eabead2fe6a200b02a8f4ec0418233111d87fd Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 9 Jul 2025 08:55:36 +0200 Subject: [PATCH 23/31] feat: #110 Add manual table assignments (#111) --- convex/_generated/api.d.ts | 18 +- .../fowV4/aggregateFowV4TournamentData.ts | 4 +- .../_helpers/deepenTournamentCompetitor.ts | 9 +- .../sortTournamentCompetitorsByName.ts | 20 ++ .../queries/getTournamentCompetitors.ts | 17 +- .../getTournamentCompetitorsByTournament.ts | 30 +- .../_helpers/generateDraftPairings.ts | 8 +- .../_helpers/generateTableAssignments.ts | 103 ------- .../_helpers/sortCompetitorPairs.ts | 16 ++ .../_helpers/sortPairingsByTable.ts | 28 ++ convex/_model/tournamentPairings/index.ts | 20 +- .../mutations/createTournamentPairings.ts | 108 +++++++ .../queries/getDraftTournamentPairings.ts | 27 +- .../getTournamentPairingsByTournament.ts | 10 +- .../mutations/createTournamentTimer.ts | 4 +- convex/_model/tournaments/README.md | 6 +- convex/_model/tournaments/index.ts | 14 +- ...urnamentRound.ts => endTournamentRound.ts} | 10 +- ...namentRound.ts => startTournamentRound.ts} | 35 +-- .../queries/getTournamentOpenRound.ts | 4 +- convex/common/errors.ts | 17 +- convex/tournamentPairings.ts | 7 +- convex/tournaments.ts | 12 +- package-lock.json | 15 - package.json | 2 - src/api.ts | 2 +- .../ConfirmationDialog.hooks.ts | 2 +- .../ConfirmationDialog.module.scss | 51 +--- .../ConfirmationDialog/ConfirmationDialog.tsx | 28 +- .../TournamentCompetitorForm.schema.ts | 6 +- .../TournamentContextMenu.tsx | 60 +++- .../ConfirmConfigureRoundDialog.module.scss | 5 + .../ConfirmConfigureRoundDialog.tsx | 95 +++++++ .../ConfirmConfigureRoundDialog.utils.tsx} | 13 +- .../ConfirmConfigureRoundDialog/index.ts | 5 + src/components/TournamentContextMenu/index.ts | 4 + .../TournamentPairingRow.tsx | 8 +- .../TournamentPairingRow.utils.tsx | 32 +-- .../Draggable/Draggable.module.scss | 10 - .../Draggable/Draggable.tsx | 40 --- .../TournamentPairingsGrid/Draggable/index.ts | 1 - .../Droppable/Droppable.module.scss | 43 --- .../Droppable/Droppable.tsx | 34 --- .../TournamentPairingsGrid/Droppable/index.ts | 1 - .../PairableCompetitorCard.module.scss | 36 --- .../PairableCompetitorCard.tsx | 37 --- .../PairableCompetitorCard/index.ts | 1 - .../PairingsGridRow.module.scss | 44 --- .../PairingsGridRow/PairingsGridRow.tsx | 80 ------ .../PairingsGridRow/PairingsGridRow.utils.ts | 25 -- .../PairingsGridRow/index.ts | 2 - .../TournamentPairingsGrid.module.scss | 55 ---- .../TournamentPairingsGrid.tsx | 197 ------------- .../TournamentPairingsGrid.types.ts | 5 - .../TournamentPairingsGrid.utils.ts | 76 ----- .../TournamentPairingsGrid/index.ts | 6 - .../InfoPopover/InfoPopover.module.scss | 1 + .../generic/InfoPopover/InfoPopover.tsx | 4 +- .../generic/Pulsar/Pulsar.module.scss | 81 ++++++ src/components/generic/Pulsar/Pulsar.tsx | 38 +++ src/components/generic/Pulsar/index.ts | 3 + .../SortableGrid/SortableGrid.module.scss | 5 + .../generic/SortableGrid/SortableGrid.tsx | 139 +++++++++ .../components/SortableItem.module.scss | 80 ++++++ .../SortableGrid/components/SortableItem.tsx | 78 +++++ .../generic/SortableGrid/components/index.ts | 3 + src/components/generic/SortableGrid/index.ts | 3 + src/components/generic/Table/Table.tsx | 2 +- src/components/generic/Table/Table.types.ts | 2 +- src/components/generic/Table/TableCell.tsx | 4 +- src/components/generic/Table/TableRow.tsx | 6 +- .../generic/Warning/Warning.module.scss | 46 +++ src/components/generic/Warning/Warning.tsx | 25 ++ src/components/generic/Warning/index.ts | 2 + src/modals.ts | 4 +- .../TournamentAdvanceRoundPage.hooks.ts | 36 --- .../TournamentAdvanceRoundPage.tsx | 88 ------ .../ConfirmPairingsDialog.module.scss | 12 - .../ConfirmPairingsDialog.tsx | 46 --- .../PairingsStep/PairingsStep.module.scss | 29 -- .../components/PairingsStep/PairingsStep.tsx | 132 --------- .../PairingsStep/PairingsStep.utils.ts | 33 --- .../components/PairingsStep/index.ts | 1 - .../RosterStep/RosterStep.module.scss | 33 --- .../components/RosterStep/RosterStep.tsx | 111 -------- .../components/RosterStep/index.ts | 1 - src/pages/TournamentAdvanceRoundPage/index.ts | 1 - .../TournamentPairingsCard.tsx | 80 +++--- .../TournamentRosterCard.tsx | 14 +- src/pages/TournamentDetailPage/index.ts | 3 + .../TournamentPairingsPage.module.scss | 78 +++++ .../TournamentPairingsPage.schema.ts | 26 ++ .../TournamentPairingsPage.tsx | 269 ++++++++++++++++++ .../TournamentPairingsPage.utils.tsx | 175 ++++++++++++ .../ConfirmPairingsDialog.module.scss | 49 ++++ .../ConfirmPairingsDialog.tsx | 82 ++++++ .../ConfirmPairingsDialog.utils.ts | 74 +++++ .../components/ConfirmPairingsDialog/index.ts | 2 +- src/pages/TournamentPairingsPage/index.ts | 3 + src/routes.tsx | 9 +- src/services/tournamentPairings.ts | 5 +- src/services/tournaments.ts | 4 +- src/settings.ts | 2 +- src/style/_variables.scss | 13 + 104 files changed, 1840 insertions(+), 1645 deletions(-) create mode 100644 convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts delete mode 100644 convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts create mode 100644 convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts create mode 100644 convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts create mode 100644 convex/_model/tournamentPairings/mutations/createTournamentPairings.ts rename convex/_model/tournaments/mutations/{closeTournamentRound.ts => endTournamentRound.ts} (87%) rename convex/_model/tournaments/mutations/{openTournamentRound.ts => startTournamentRound.ts} (53%) create mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss create mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx rename src/{pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx => components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx} (66%) create mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts delete mode 100644 src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss delete mode 100644 src/components/TournamentPairingsGrid/Draggable/Draggable.tsx delete mode 100644 src/components/TournamentPairingsGrid/Draggable/index.ts delete mode 100644 src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss delete mode 100644 src/components/TournamentPairingsGrid/Droppable/Droppable.tsx delete mode 100644 src/components/TournamentPairingsGrid/Droppable/index.ts delete mode 100644 src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss delete mode 100644 src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx delete mode 100644 src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts delete mode 100644 src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss delete mode 100644 src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx delete mode 100644 src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts delete mode 100644 src/components/TournamentPairingsGrid/PairingsGridRow/index.ts delete mode 100644 src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss delete mode 100644 src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx delete mode 100644 src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts delete mode 100644 src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts delete mode 100644 src/components/TournamentPairingsGrid/index.ts create mode 100644 src/components/generic/Pulsar/Pulsar.module.scss create mode 100644 src/components/generic/Pulsar/Pulsar.tsx create mode 100644 src/components/generic/Pulsar/index.ts create mode 100644 src/components/generic/SortableGrid/SortableGrid.module.scss create mode 100644 src/components/generic/SortableGrid/SortableGrid.tsx create mode 100644 src/components/generic/SortableGrid/components/SortableItem.module.scss create mode 100644 src/components/generic/SortableGrid/components/SortableItem.tsx create mode 100644 src/components/generic/SortableGrid/components/index.ts create mode 100644 src/components/generic/SortableGrid/index.ts create mode 100644 src/components/generic/Warning/Warning.module.scss create mode 100644 src/components/generic/Warning/Warning.tsx create mode 100644 src/components/generic/Warning/index.ts delete mode 100644 src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts delete mode 100644 src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx delete mode 100644 src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts delete mode 100644 src/pages/TournamentAdvanceRoundPage/index.ts create mode 100644 src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss create mode 100644 src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts create mode 100644 src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx create mode 100644 src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx create mode 100644 src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss create mode 100644 src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx create mode 100644 src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts rename src/pages/{TournamentAdvanceRoundPage => TournamentPairingsPage}/components/ConfirmPairingsDialog/index.ts (66%) create mode 100644 src/pages/TournamentPairingsPage/index.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 35ab2830..6ec3b664 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -66,6 +66,7 @@ import type * as _model_matchResults_queries_getMatchResultsByTournament from ". import type * as _model_matchResults_queries_getMatchResultsByTournamentPairing from "../_model/matchResults/queries/getMatchResultsByTournamentPairing.js"; import type * as _model_matchResults_queries_getMatchResultsByTournamentRound from "../_model/matchResults/queries/getMatchResultsByTournamentRound.js"; import type * as _model_tournamentCompetitors__helpers_deepenTournamentCompetitor from "../_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.js"; +import type * as _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName from "../_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.js"; import type * as _model_tournamentCompetitors_fields from "../_model/tournamentCompetitors/fields.js"; import type * as _model_tournamentCompetitors_index from "../_model/tournamentCompetitors/index.js"; import type * as _model_tournamentCompetitors_mutations_addTournamentCompetitorPlayer from "../_model/tournamentCompetitors/mutations/addTournamentCompetitorPlayer.js"; @@ -80,13 +81,15 @@ import type * as _model_tournamentCompetitors_queries_getTournamentCompetitorsBy import type * as _model_tournamentPairings__helpers_assignBye from "../_model/tournamentPairings/_helpers/assignBye.js"; import type * as _model_tournamentPairings__helpers_deepenTournamentPairing from "../_model/tournamentPairings/_helpers/deepenTournamentPairing.js"; import type * as _model_tournamentPairings__helpers_generateDraftPairings from "../_model/tournamentPairings/_helpers/generateDraftPairings.js"; -import type * as _model_tournamentPairings__helpers_generateTableAssignments from "../_model/tournamentPairings/_helpers/generateTableAssignments.js"; import type * as _model_tournamentPairings__helpers_getTournamentPairingDeep from "../_model/tournamentPairings/_helpers/getTournamentPairingDeep.js"; import type * as _model_tournamentPairings__helpers_getTournamentPairingShallow from "../_model/tournamentPairings/_helpers/getTournamentPairingShallow.js"; import type * as _model_tournamentPairings__helpers_shuffle from "../_model/tournamentPairings/_helpers/shuffle.js"; import type * as _model_tournamentPairings__helpers_sortByRank from "../_model/tournamentPairings/_helpers/sortByRank.js"; +import type * as _model_tournamentPairings__helpers_sortCompetitorPairs from "../_model/tournamentPairings/_helpers/sortCompetitorPairs.js"; +import type * as _model_tournamentPairings__helpers_sortPairingsByTable from "../_model/tournamentPairings/_helpers/sortPairingsByTable.js"; import type * as _model_tournamentPairings_fields from "../_model/tournamentPairings/fields.js"; import type * as _model_tournamentPairings_index from "../_model/tournamentPairings/index.js"; +import type * as _model_tournamentPairings_mutations_createTournamentPairings from "../_model/tournamentPairings/mutations/createTournamentPairings.js"; import type * as _model_tournamentPairings_queries_getActiveTournamentPairingsByUser from "../_model/tournamentPairings/queries/getActiveTournamentPairingsByUser.js"; import type * as _model_tournamentPairings_queries_getDraftTournamentPairings from "../_model/tournamentPairings/queries/getDraftTournamentPairings.js"; import type * as _model_tournamentPairings_queries_getTournamentPairing from "../_model/tournamentPairings/queries/getTournamentPairing.js"; @@ -112,13 +115,13 @@ import type * as _model_tournaments__helpers_getTournamentShallow from "../_mode import type * as _model_tournaments__helpers_getTournamentUserIds from "../_model/tournaments/_helpers/getTournamentUserIds.js"; import type * as _model_tournaments_fields from "../_model/tournaments/fields.js"; import type * as _model_tournaments_index from "../_model/tournaments/index.js"; -import type * as _model_tournaments_mutations_closeTournamentRound from "../_model/tournaments/mutations/closeTournamentRound.js"; import type * as _model_tournaments_mutations_createTournament from "../_model/tournaments/mutations/createTournament.js"; import type * as _model_tournaments_mutations_deleteTournament from "../_model/tournaments/mutations/deleteTournament.js"; import type * as _model_tournaments_mutations_endTournament from "../_model/tournaments/mutations/endTournament.js"; -import type * as _model_tournaments_mutations_openTournamentRound from "../_model/tournaments/mutations/openTournamentRound.js"; +import type * as _model_tournaments_mutations_endTournamentRound from "../_model/tournaments/mutations/endTournamentRound.js"; import type * as _model_tournaments_mutations_publishTournament from "../_model/tournaments/mutations/publishTournament.js"; import type * as _model_tournaments_mutations_startTournament from "../_model/tournaments/mutations/startTournament.js"; +import type * as _model_tournaments_mutations_startTournamentRound from "../_model/tournaments/mutations/startTournamentRound.js"; import type * as _model_tournaments_mutations_updateTournament from "../_model/tournaments/mutations/updateTournament.js"; import type * as _model_tournaments_queries_getTournament from "../_model/tournaments/queries/getTournament.js"; import type * as _model_tournaments_queries_getTournamentOpenRound from "../_model/tournaments/queries/getTournamentOpenRound.js"; @@ -249,6 +252,7 @@ declare const fullApi: ApiFromModules<{ "_model/matchResults/queries/getMatchResultsByTournamentPairing": typeof _model_matchResults_queries_getMatchResultsByTournamentPairing; "_model/matchResults/queries/getMatchResultsByTournamentRound": typeof _model_matchResults_queries_getMatchResultsByTournamentRound; "_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor": typeof _model_tournamentCompetitors__helpers_deepenTournamentCompetitor; + "_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName; "_model/tournamentCompetitors/fields": typeof _model_tournamentCompetitors_fields; "_model/tournamentCompetitors/index": typeof _model_tournamentCompetitors_index; "_model/tournamentCompetitors/mutations/addTournamentCompetitorPlayer": typeof _model_tournamentCompetitors_mutations_addTournamentCompetitorPlayer; @@ -263,13 +267,15 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentPairings/_helpers/assignBye": typeof _model_tournamentPairings__helpers_assignBye; "_model/tournamentPairings/_helpers/deepenTournamentPairing": typeof _model_tournamentPairings__helpers_deepenTournamentPairing; "_model/tournamentPairings/_helpers/generateDraftPairings": typeof _model_tournamentPairings__helpers_generateDraftPairings; - "_model/tournamentPairings/_helpers/generateTableAssignments": typeof _model_tournamentPairings__helpers_generateTableAssignments; "_model/tournamentPairings/_helpers/getTournamentPairingDeep": typeof _model_tournamentPairings__helpers_getTournamentPairingDeep; "_model/tournamentPairings/_helpers/getTournamentPairingShallow": typeof _model_tournamentPairings__helpers_getTournamentPairingShallow; "_model/tournamentPairings/_helpers/shuffle": typeof _model_tournamentPairings__helpers_shuffle; "_model/tournamentPairings/_helpers/sortByRank": typeof _model_tournamentPairings__helpers_sortByRank; + "_model/tournamentPairings/_helpers/sortCompetitorPairs": typeof _model_tournamentPairings__helpers_sortCompetitorPairs; + "_model/tournamentPairings/_helpers/sortPairingsByTable": typeof _model_tournamentPairings__helpers_sortPairingsByTable; "_model/tournamentPairings/fields": typeof _model_tournamentPairings_fields; "_model/tournamentPairings/index": typeof _model_tournamentPairings_index; + "_model/tournamentPairings/mutations/createTournamentPairings": typeof _model_tournamentPairings_mutations_createTournamentPairings; "_model/tournamentPairings/queries/getActiveTournamentPairingsByUser": typeof _model_tournamentPairings_queries_getActiveTournamentPairingsByUser; "_model/tournamentPairings/queries/getDraftTournamentPairings": typeof _model_tournamentPairings_queries_getDraftTournamentPairings; "_model/tournamentPairings/queries/getTournamentPairing": typeof _model_tournamentPairings_queries_getTournamentPairing; @@ -295,13 +301,13 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/_helpers/getTournamentUserIds": typeof _model_tournaments__helpers_getTournamentUserIds; "_model/tournaments/fields": typeof _model_tournaments_fields; "_model/tournaments/index": typeof _model_tournaments_index; - "_model/tournaments/mutations/closeTournamentRound": typeof _model_tournaments_mutations_closeTournamentRound; "_model/tournaments/mutations/createTournament": typeof _model_tournaments_mutations_createTournament; "_model/tournaments/mutations/deleteTournament": typeof _model_tournaments_mutations_deleteTournament; "_model/tournaments/mutations/endTournament": typeof _model_tournaments_mutations_endTournament; - "_model/tournaments/mutations/openTournamentRound": typeof _model_tournaments_mutations_openTournamentRound; + "_model/tournaments/mutations/endTournamentRound": typeof _model_tournaments_mutations_endTournamentRound; "_model/tournaments/mutations/publishTournament": typeof _model_tournaments_mutations_publishTournament; "_model/tournaments/mutations/startTournament": typeof _model_tournaments_mutations_startTournament; + "_model/tournaments/mutations/startTournamentRound": typeof _model_tournaments_mutations_startTournamentRound; "_model/tournaments/mutations/updateTournament": typeof _model_tournaments_mutations_updateTournament; "_model/tournaments/queries/getTournament": typeof _model_tournaments_queries_getTournament; "_model/tournaments/queries/getTournamentOpenRound": typeof _model_tournaments_queries_getTournamentOpenRound; diff --git a/convex/_model/fowV4/aggregateFowV4TournamentData.ts b/convex/_model/fowV4/aggregateFowV4TournamentData.ts index b2e4c124..1af355e7 100644 --- a/convex/_model/fowV4/aggregateFowV4TournamentData.ts +++ b/convex/_model/fowV4/aggregateFowV4TournamentData.ts @@ -131,11 +131,11 @@ export const aggregateFowV4TournamentData = async ( } return { - players: flattenFowV4StatMap(playerStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ + players: flattenFowV4StatMap(playerStats).map(({ id, stats }) => ({ id, stats, })), - competitors: flattenFowV4StatMap(competitorStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ + competitors: flattenFowV4StatMap(competitorStats).map(({ id, stats }) => ({ id, stats, ...competitorMeta[id], diff --git a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts index 1f3778ad..276858b6 100644 --- a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts @@ -1,4 +1,4 @@ -import { Doc } from '../../../_generated/dataModel'; +import { Doc, Id } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { LimitedUser } from '../../users/_helpers/redactUser'; import { getUser } from '../../users/queries/getUser'; @@ -17,6 +17,12 @@ import { getUser } from '../../users/queries/getUser'; export const deepenTournamentCompetitor = async ( ctx: QueryCtx, tournamentCompetitor: Doc<'tournamentCompetitors'>, + results?: { + playedTables: (number | null)[]; + opponentIds: Id<'tournamentCompetitors'>[]; + byeRounds: number[]; + rank: number; + }, ) => { const players = await Promise.all(tournamentCompetitor.players.map(async ({ active, userId }) => ({ active, @@ -29,6 +35,7 @@ export const deepenTournamentCompetitor = async ( return { ...tournamentCompetitor, + ...results, players: players.filter(playerHasUser), }; }; diff --git a/convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts b/convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts new file mode 100644 index 00000000..7049d5fd --- /dev/null +++ b/convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts @@ -0,0 +1,20 @@ +import { DeepTournamentCompetitor } from './deepenTournamentCompetitor'; + +export const sortTournamentCompetitorsByName = ( + a: DeepTournamentCompetitor, + b: DeepTournamentCompetitor, +): number => { + const getSortValue = (competitor: DeepTournamentCompetitor): string => { + if (competitor.teamName) { + return competitor.teamName; + } + if (competitor.players[0]?.user.familyName) { + return competitor.players[0].user.familyName; + } + if (competitor.players[0]?.user.username) { + return competitor.players[0].user.username; + } + return ''; + }; + return getSortValue(a).localeCompare(getSortValue(b)); +}; diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts index 479846f8..3537d69c 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts @@ -1,5 +1,6 @@ import { QueryCtx } from '../../../_generated/server'; import { deepenTournamentCompetitor, DeepTournamentCompetitor } from '../_helpers/deepenTournamentCompetitor'; +import { sortTournamentCompetitorsByName } from '../_helpers/sortTournamentCompetitorsByName'; export const getTournamentCompetitors = async ( ctx: QueryCtx, @@ -8,19 +9,5 @@ export const getTournamentCompetitors = async ( const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( async (item) => await deepenTournamentCompetitor(ctx, item), )); - return deepTournamentCompetitors.sort((a, b) => { - const getSortValue = (competitor: DeepTournamentCompetitor): string => { - if (competitor.teamName) { - return competitor.teamName; - } - if (competitor.players[0].user.familyName) { - return competitor.players[0].user.familyName; - } - if (competitor.players[0].user.username) { - return competitor.players[0].user.username; - } - return ''; - }; - return getSortValue(a).localeCompare(getSortValue(b)); - }); + return deepTournamentCompetitors.sort(sortTournamentCompetitorsByName); }; diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index de9087c9..2cf22a39 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -1,10 +1,13 @@ import { Infer,v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; +import { getTournamentRankings } from '../../tournaments'; import { deepenTournamentCompetitor,DeepTournamentCompetitor } from '../_helpers/deepenTournamentCompetitor'; +import { sortTournamentCompetitorsByName } from '../_helpers/sortTournamentCompetitorsByName'; export const getTournamentCompetitorsByTournamentArgs = v.object({ tournamentId: v.id('tournaments'), + includeRankings: v.optional(v.number()), }); export const getTournamentCompetitorsByTournament = async ( @@ -14,22 +17,13 @@ export const getTournamentCompetitorsByTournament = async ( const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( - async (item) => await deepenTournamentCompetitor(ctx, item), - )); - return deepTournamentCompetitors.sort((a, b) => { - const getSortValue = (competitor: DeepTournamentCompetitor): string => { - if (competitor.teamName) { - return competitor.teamName; - } - if (competitor.players[0]?.user.familyName) { - return competitor.players[0].user.familyName; - } - if (competitor.players[0]?.user.username) { - return competitor.players[0].user.username; - } - return ''; - }; - return getSortValue(a).localeCompare(getSortValue(b)); - }); + const rankings = args.includeRankings && args.includeRankings > -1 ? await getTournamentRankings(ctx, { + tournamentId: args.tournamentId, + round: args.includeRankings, + }) : undefined; + const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map(async (item) => { + const results = rankings?.competitors.find((c) => c.id === item._id); + return await deepenTournamentCompetitor(ctx, item, results ); + })); + return deepTournamentCompetitors.sort(sortTournamentCompetitorsByName); }; diff --git a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts index 117dbcd4..743e0175 100644 --- a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts +++ b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts @@ -7,7 +7,7 @@ import { assignBye } from './assignBye'; /** * A tuple of TournamentCompetitorRanked's to be paired. */ -export type DraftTournamentPairing = [TournamentCompetitorRanked, TournamentCompetitorRanked | null]; +export type CompetitorPair = [TournamentCompetitorRanked, TournamentCompetitorRanked | null]; /** * Generates draft pairings for an array of ranked TournamentCompetitors. @@ -23,8 +23,8 @@ export type DraftTournamentPairing = [TournamentCompetitorRanked, TournamentComp export const generateDraftPairings = ( orderedCompetitors: TournamentCompetitorRanked[], allowRepeats: boolean = false, -): DraftTournamentPairing[] => { - const pairings: DraftTournamentPairing[] = []; +): CompetitorPair[] => { + const pairings: CompetitorPair[] = []; // Handle byes: const [byeCompetitor, restCompetitors]= assignBye(orderedCompetitors); @@ -52,7 +52,7 @@ export const generateDraftPairings = ( export const recursivePair = ( pool: TournamentCompetitorRanked[], allowRepeats: boolean, -): DraftTournamentPairing[] | null => { +): CompetitorPair[] | null => { if (pool.length === 0) { return []; // everyone paired } diff --git a/convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts b/convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts deleted file mode 100644 index ef6814d0..00000000 --- a/convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Infer, v } from 'convex/values'; -import blossom from 'edmonds-blossom'; - -import { Id } from '../../../_generated/dataModel'; - -export const unassignedTournamentPairingFields = v.object({ - playedTables: v.array(v.union(v.number(), v.null())), - tournamentCompetitor0Id: v.id('tournamentCompetitors'), - tournamentCompetitor1Id: v.union(v.id('tournamentCompetitors'), v.null()), -}); - -export type UnassignedTournamentPairing = Infer; - -// TODO: DOCUMENTATION - -export type AssignedTournamentPairing = { - tournamentCompetitor0Id: Id<'tournamentCompetitors'>; - tournamentCompetitor1Id: Id<'tournamentCompetitors'> | null; - table: number | null; -}; - -export const generateTableAssignments = ( - draftPairings: { - playedTables: (number | null)[], - tournamentCompetitor0Id: Id<'tournamentCompetitors'>, - tournamentCompetitor1Id: Id<'tournamentCompetitors'> | null, - }[], - tableCount: number, -): AssignedTournamentPairing[] => { - const fullPairs = draftPairings.filter( - (p) => p.tournamentCompetitor0Id && p.tournamentCompetitor1Id, - ); - const partialPairs = draftPairings.filter( - (p) => !p.tournamentCompetitor0Id || !p.tournamentCompetitor1Id, - ); - - const tableIndices = Array.from({ length: tableCount }, (_, i) => i); - - // Create a bipartite graph between full pairs and tables. - // We minimize "repeats" by assigning higher weights to edges that go to "new" tables. - - const edges: [number, number, number][] = []; - const tableOffset = fullPairs.length; - - fullPairs.forEach((pair, i) => { - const playedTables = pair.playedTables; - tableIndices.forEach((table) => { - const hasPlayed = playedTables.includes(table); - const weight = hasPlayed ? 0 : 1; // Prefer unplayed tables (higher weight) - edges.push([i, tableOffset + table, weight]); - }); - }); - - const match = blossom(edges); - - const assignedPairs: AssignedTournamentPairing[] = []; - - const usedTables = new Set(); - - // Match results come back as an array of matched node index => node index. - const tableAssignments: Map = new Map(); - - match.forEach((matchedIdx, idx) => { - if (idx < tableOffset && matchedIdx >= tableOffset) { - const pairIndex = idx; - const table = matchedIdx - tableOffset; - tableAssignments.set(pairIndex, table); - usedTables.add(table); - } - }); - - // Process full pairs with assigned tables - fullPairs.forEach(({ tournamentCompetitor0Id, tournamentCompetitor1Id }, index) => { - const table = tableAssignments.get(index); - assignedPairs.push({ - tournamentCompetitor0Id, - tournamentCompetitor1Id, - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - table: table ?? (() => { - // If not matched, assign first available (even if it's a repeat) - for (let t = 0; t < tableCount; t++) { - if (!usedTables.has(t)) { - usedTables.add(t); - return t; - } - } - // All tables used, allow repeats - return index % tableCount; - })(), - }); - }); - - // Process partial pairs by assigning them to remaining tables - partialPairs.forEach(({ tournamentCompetitor0Id, tournamentCompetitor1Id }) => { - assignedPairs.push({ - tournamentCompetitor0Id, - tournamentCompetitor1Id, - table: null, // If they don't play, don't make future table assignments more difficult by counting this one - }); - }); - - return assignedPairs; -}; diff --git a/convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts b/convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts new file mode 100644 index 00000000..fbce9309 --- /dev/null +++ b/convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts @@ -0,0 +1,16 @@ +import { CompetitorPair } from './generateDraftPairings'; + +export const sortCompetitorPairs = ( + a: CompetitorPair, + b: CompetitorPair, +): -1 | 0 | 1 => { + const aHasNull = a[0] === null || a[1] === null; + const bHasNull = b[0] === null || b[1] === null; + if (aHasNull && !bHasNull) { + return 1; + } + if (!aHasNull && bHasNull) { + return -1; + } + return 0; +}; diff --git a/convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts b/convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts new file mode 100644 index 00000000..ff6344b2 --- /dev/null +++ b/convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts @@ -0,0 +1,28 @@ +import { ShallowTournamentPairing } from '..'; +import { DraftTournamentPairing } from '../queries/getDraftTournamentPairings'; +import { TournamentPairingDeep } from './deepenTournamentPairing'; + +type AnyPairing = TournamentPairingDeep | ShallowTournamentPairing | DraftTournamentPairing; + +export const sortPairingsByTable = ( + pairings: T[], +): T[] => pairings.sort((a, b) => { + const aTable = a.table; + const bTable = b.table; + + // Handle null or undefined table values + const aIsNull = aTable === null || aTable === undefined; + const bIsNull = bTable === null || bTable === undefined; + + if (aIsNull && bIsNull) { + return 0; + } + if (aIsNull) { + return 1; + } + if (bIsNull) { + return -1; + } + + return aTable - bTable; +}); diff --git a/convex/_model/tournamentPairings/index.ts b/convex/_model/tournamentPairings/index.ts index 377e8cfa..9930067d 100644 --- a/convex/_model/tournamentPairings/index.ts +++ b/convex/_model/tournamentPairings/index.ts @@ -1,6 +1,6 @@ import { defineTable } from 'convex/server'; -import { Id } from '../../_generated/dataModel'; +import { Doc, Id } from '../../_generated/dataModel'; import { computedFields, editableFields } from './fields'; export const tournamentPairingsTable = defineTable({ @@ -10,21 +10,14 @@ export const tournamentPairingsTable = defineTable({ .index('by_tournament_id', ['tournamentId']); export type TournamentPairingId = Id<'tournamentPairings'>; +export type ShallowTournamentPairing = Doc<'tournamentPairings'>; // Helpers export { deepenTournamentPairing, type TournamentPairingDeep, } from './_helpers/deepenTournamentPairing'; -export { - type DraftTournamentPairing, - generateDraftPairings, -} from './_helpers/generateDraftPairings'; -export { - generateTableAssignments, - type UnassignedTournamentPairing, - unassignedTournamentPairingFields, -} from './_helpers/generateTableAssignments'; +export { generateDraftPairings } from './_helpers/generateDraftPairings'; export { getTournamentPairingDeep } from './_helpers/getTournamentPairingDeep'; export { getTournamentPairingShallow } from './_helpers/getTournamentPairingShallow'; export { shuffle } from './_helpers/shuffle'; @@ -36,6 +29,7 @@ export { getActiveTournamentPairingsByUserArgs, } from './queries/getActiveTournamentPairingsByUser'; export { + type DraftTournamentPairing, getDraftTournamentPairings, getDraftTournamentPairingsArgs, } from './queries/getDraftTournamentPairings'; @@ -51,3 +45,9 @@ export { getTournamentPairingsByTournament, getTournamentPairingsByTournamentArgs, } from './queries/getTournamentPairingsByTournament'; + +// Mutations +export { + createTournamentPairings, + createTournamentPairingsArgs, +} from './mutations/createTournamentPairings'; diff --git a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts new file mode 100644 index 00000000..5c154a4b --- /dev/null +++ b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts @@ -0,0 +1,108 @@ +import { + ConvexError, + Infer, + v, +} from 'convex/values'; + +import { Id } from '../../../_generated/dataModel'; +import { MutationCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../../common/errors'; +import { getTournamentCompetitorsByTournament } from '../../tournamentCompetitors'; +import { getTournamentPairings } from '../../tournamentPairings'; +import { checkTournamentAuth, getTournamentShallow } from '../../tournaments'; +import { sharedFields, uniqueFields } from '../fields'; + +export const createTournamentPairingsArgs = v.object({ + ...sharedFields, + pairings: v.array(v.object({ + ...uniqueFields, + })), +}); + +export const createTournamentPairings = async ( + ctx: MutationCtx, + args: Infer, +): Promise[]> => { + const tournament = await getTournamentShallow(ctx, args.tournamentId); + const competitors = await getTournamentCompetitorsByTournament(ctx, { + tournamentId: args.tournamentId, + }); + const existingPairings = await getTournamentPairings(ctx, { + tournamentId: args.tournamentId, + round: args.round, + }); + + // --- CHECK AUTH ---- + checkTournamentAuth(ctx, tournament); + + // ---- VALIDATE ---- + if (tournament.status === 'draft') { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT')); + } + if (tournament.status === 'published') { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT')); + } + if (tournament.status === 'archived') { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT')); + } + if (tournament.currentRound !== undefined) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_IN_PROGRESS_ROUND')); + } + if (existingPairings.length) { + throw new ConvexError(getErrorMessage('TOURNAMENT_ALREADY_HAS_PAIRINGS_FOR_ROUND')); + } + if (!args.pairings.length) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_EMPTY_PAIRINGS_LIST')); + } + if (args.pairings.length > Math.ceil(tournament.maxCompetitors / 2)) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_TOO_MANY_PAIRINGS')); + } + + const pairingIds: Id<'tournamentPairings'>[] = []; + const pairedCompetitorIds = new Set>(); + + for (const pairing of args.pairings) { + + // ---- VALIDATE EACH PAIRING ---- + for (const id of [pairing.tournamentCompetitor0Id, pairing.tournamentCompetitor1Id]) { + const competitor = competitors.find((c) => c._id === id); + const activePlayers = (competitor?.players ?? []).filter((p) => p.active); + if (id !== null) { + if (!competitor) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_MISSING_COMPETITOR')); + } + if (!competitor?.active) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_INACTIVE_COMPETITOR')); + } + if (pairedCompetitorIds.has(pairing.tournamentCompetitor0Id)) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR')); + } + if (activePlayers.length < tournament.competitorSize) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_UNDER_STRENGTH_COMPETITOR')); + } + if (activePlayers.length > tournament.competitorSize) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_OVER_STRENGTH_COMPETITOR')); + } + } + } + + // ---- PRIMARY ACTIONS ---- + const id = await ctx.db.insert('tournamentPairings', { + ...pairing, + tournamentId: args.tournamentId, + round: args.round, + }); + + // ---- TRACK RESULTS ---- + pairingIds.push(id); + pairedCompetitorIds.add(pairing.tournamentCompetitor0Id); + if (pairing.tournamentCompetitor1Id) { + pairedCompetitorIds.add(pairing.tournamentCompetitor1Id); + } + } + + // TODO: Throw error if tables are double-assigned + // TODO: Throw error if competitors are double-assigned + + return pairingIds; +}; diff --git a/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts b/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts index 7ef4aa75..43aa1358 100644 --- a/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts +++ b/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts @@ -3,11 +3,15 @@ import { Infer, v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; import { tournamentPairingMethod } from '../../../static/tournamentPairingMethods'; import { getTournamentCompetitorsByTournament } from '../../tournamentCompetitors'; -import { getTournamentRankings } from '../../tournaments'; +import { getTournamentRankings, TournamentCompetitorRanked } from '../../tournaments'; import { generateDraftPairings } from '../_helpers/generateDraftPairings'; -import { DraftTournamentPairing } from '../_helpers/generateDraftPairings'; import { shuffle } from '../_helpers/shuffle'; import { sortByRank } from '../_helpers/sortByRank'; +import { sortCompetitorPairs } from '../_helpers/sortCompetitorPairs'; +import { uniqueFields } from '../fields'; + +const draftTournamentPairing = v.object(uniqueFields); +export type DraftTournamentPairing = Infer; export const getDraftTournamentPairingsArgs = v.object({ method: tournamentPairingMethod, @@ -34,14 +38,21 @@ export const getDraftTournamentPairings = async ( tournamentId: args.tournamentId, round: args.round - 1, // Get rankings for previous round }); - const activeCompetitors = rankedCompetitors.filter(({ id }) => !!competitors.find((c) => c._id === id && c.active)); + const activeCompetitors = rankedCompetitors.filter(({ id }) => ( + !!competitors.find((c) => c._id === id && c.active) + )); + const orderedCompetitors: TournamentCompetitorRanked[] = []; if (args.method === 'adjacent') { - const orderedCompetitors = sortByRank(activeCompetitors); - return generateDraftPairings(orderedCompetitors); + orderedCompetitors.push(...sortByRank(activeCompetitors)); } if (args.method === 'random') { - const orderedCompetitors = shuffle(activeCompetitors); - return generateDraftPairings(orderedCompetitors); + orderedCompetitors.push(...shuffle(activeCompetitors)); } - return []; + return generateDraftPairings(orderedCompetitors).sort(sortCompetitorPairs).map((draftPairing) => ({ + tournamentId: args.tournamentId, + tournamentCompetitor0Id: draftPairing[0].id, + tournamentCompetitor1Id: draftPairing[1]?.id ?? null, + table: -1, + round: args.round, + })); }; diff --git a/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts b/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts index aabfac3f..bda62241 100644 --- a/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts +++ b/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts @@ -26,5 +26,13 @@ export const getTournamentPairingsByTournament = async ( const deepTournamentPairings = await Promise.all( tournamentPairings.map(async (tournamentPairing) => await deepenTournamentPairing(ctx, tournamentPairing)), ); - return deepTournamentPairings.filter(notNullOrUndefined); + return deepTournamentPairings.filter(notNullOrUndefined).sort((a, b) => { + if (a.table === null) { + return 1; + } + if (b.table === null) { + return -1; + } + return a.table - b.table; + }); }; diff --git a/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts b/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts index 7c2b2c84..0d03c0ee 100644 --- a/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts +++ b/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts @@ -36,8 +36,8 @@ export const createTournamentTimer = async ( } return await ctx.db.insert('tournamentTimers', { ...args, - pausedAt: Date.now(), + pausedAt: null, pauseTime: 0, - startedAt: null, + startedAt: Date.now(), }); }; diff --git a/convex/_model/tournaments/README.md b/convex/_model/tournaments/README.md index 4125ea7a..81a9526e 100644 --- a/convex/_model/tournaments/README.md +++ b/convex/_model/tournaments/README.md @@ -7,7 +7,7 @@ | `createTournament()` | Editing | `'draft'` | `unset` | | `publishTournament()` | Published | `'published'` | `unset` | | `startTournament()` | Round 1 Set-Up | `'active'` | `unset` | -| `openTournamentRound()` | Round 1 Play | `'active'` | `0` | -| `closeTournamentRound()` | Round 2 Set-Up | `'active'` | `unset` | -| `openTournamentRound()` | Round 2 Play | `'active'` | `1` | +| `startTournamentRound()` | Round 1 Play | `'active'` | `0` | +| `endTournamentRound()` | Round 2 Set-Up | `'active'` | `unset` | +| `startTournamentRound()` | Round 2 Play | `'active'` | `1` | | `endTournament()` | Archived | `'archived'` | `unset` | \ No newline at end of file diff --git a/convex/_model/tournaments/index.ts b/convex/_model/tournaments/index.ts index 5493bd62..68172dbe 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -20,10 +20,6 @@ export { getTournamentShallow } from './_helpers/getTournamentShallow'; export { getTournamentUserIds } from './_helpers/getTournamentUserIds'; // Mutations -export { - closeTournamentRound, - closeTournamentRoundArgs, -} from './mutations/closeTournamentRound'; export { createTournament, createTournamentArgs, @@ -37,9 +33,9 @@ export { endTournamentArgs, } from './mutations/endTournament'; export { - openTournamentRound, - openTournamentRoundArgs, -} from './mutations/openTournamentRound'; + endTournamentRound, + endTournamentRoundArgs, +} from './mutations/endTournamentRound'; export { publishTournament, publishTournamentArgs, @@ -48,6 +44,10 @@ export { startTournament, startTournamentArgs, } from './mutations/startTournament'; +export { + startTournamentRound, + startTournamentRoundArgs, +} from './mutations/startTournamentRound'; export { updateTournament, updateTournamentArgs, diff --git a/convex/_model/tournaments/mutations/closeTournamentRound.ts b/convex/_model/tournaments/mutations/endTournamentRound.ts similarity index 87% rename from convex/_model/tournaments/mutations/closeTournamentRound.ts rename to convex/_model/tournaments/mutations/endTournamentRound.ts index ef9f2bc5..95364505 100644 --- a/convex/_model/tournaments/mutations/closeTournamentRound.ts +++ b/convex/_model/tournaments/mutations/endTournamentRound.ts @@ -10,20 +10,20 @@ import { deleteTournamentTimerByTournament } from '../../tournamentTimers'; import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; import { getTournamentShallow } from '../_helpers/getTournamentShallow'; -export const closeTournamentRoundArgs = v.object({ +export const endTournamentRoundArgs = v.object({ id: v.id('tournaments'), }); /** - * Closes a currently open Tournament round. + * Ends a currently open tournament round. * * @param ctx - Convex mutation context * @param args - Convex mutation args - * @param args.id - ID of the Tournament + * @param args.id - ID of the tournament */ -export const closeTournamentRound = async ( +export const endTournamentRound = async ( ctx: MutationCtx, - args: Infer, + args: Infer, ): Promise => { const tournament = await getTournamentShallow(ctx, args.id); diff --git a/convex/_model/tournaments/mutations/openTournamentRound.ts b/convex/_model/tournaments/mutations/startTournamentRound.ts similarity index 53% rename from convex/_model/tournaments/mutations/openTournamentRound.ts rename to convex/_model/tournaments/mutations/startTournamentRound.ts index b9ba3bcb..d4f28697 100644 --- a/convex/_model/tournaments/mutations/openTournamentRound.ts +++ b/convex/_model/tournaments/mutations/startTournamentRound.ts @@ -6,27 +6,24 @@ import { import { MutationCtx } from '../../../_generated/server'; import { getErrorMessage } from '../../../common/errors'; -import { generateTableAssignments, unassignedTournamentPairingFields } from '../../tournamentPairings'; import { createTournamentTimer } from '../../tournamentTimers'; import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; import { getTournamentShallow } from '../_helpers/getTournamentShallow'; -export const openTournamentRoundArgs = v.object({ +export const startTournamentRoundArgs = v.object({ id: v.id('tournaments'), - unassignedPairings: v.array(unassignedTournamentPairingFields), }); /** - * Finalizes draft TournamentPairings and opens a new Tournament round. + * Starts a new tournament round. * * @param ctx - Convex query context * @param args - Convex query args - * @param args.id - ID of the Tournament - * @param args.unassignedPairings - Draft TournamentPairings to assign to tables + * @param args.id - ID of the tournament */ -export const openTournamentRound = async ( +export const startTournamentRound = async ( ctx: MutationCtx, - args: Infer, + args: Infer, ): Promise => { const tournament = await getTournamentShallow(ctx, args.id); @@ -47,30 +44,10 @@ export const openTournamentRound = async ( throw new ConvexError(getErrorMessage('TOURNAMENT_ALREADY_HAS_OPEN_ROUND')); } - // TODO: Throw error if missing pairings - // TODO: Throw error if pairings are invalid - // TODO: Throw error if there are too many pairings or some competitors are not active, etc. - // TODO: Throw error if pairings for that round already exist - // TODO: Throw error if competitors have the wrong number of (active) players - // ---- PRIMARY ACTIONS ---- - const tableCount = Math.ceil(tournament.maxCompetitors / 2); const nextRound = (tournament.lastRound ?? -1) + 1; - // Assign pairings to tables: - const assignedPairings = generateTableAssignments(args.unassignedPairings, tableCount); - - // Create pairing records: - Promise.all(assignedPairings.map(async (pairing) => ( - // TODO: Make a mutation? - await ctx.db.insert('tournamentPairings', { - ...pairing, - round: nextRound, - tournamentId: args.id, - }) - ))); - - // Create a timer for the upcoming round: + // Create (and start) a timer for the upcoming round: await createTournamentTimer(ctx, { tournamentId: tournament._id, round: nextRound, diff --git a/convex/_model/tournaments/queries/getTournamentOpenRound.ts b/convex/_model/tournaments/queries/getTournamentOpenRound.ts index 1623bc72..2643fa5d 100644 --- a/convex/_model/tournaments/queries/getTournamentOpenRound.ts +++ b/convex/_model/tournaments/queries/getTournamentOpenRound.ts @@ -46,8 +46,8 @@ export const getTournamentOpenRound = async ( return { round: tournament.currentRound, matchResultsProgress: { - submitted: relevantPairingIds.length * tournament.competitorSize, - required: relevantMatchResultIds.length, + required: relevantPairingIds.length * tournament.competitorSize, + submitted: relevantMatchResultIds.length, }, // TODO: Get timer }; diff --git a/convex/common/errors.ts b/convex/common/errors.ts index 90a52f21..ec67e853 100644 --- a/convex/common/errors.ts +++ b/convex/common/errors.ts @@ -13,11 +13,6 @@ export const errors = { CANNOT_REMOVE_COMPETITOR_FROM_ACTIVE_TOURNAMENT: 'Cannot add a competitor to an on-going tournament.', CANNOT_MODIFY_ANOTHER_TOURNAMENT_COMPETITOR: 'Cannot modify another tournament competitor.', - // Tournament (specific) - CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT: 'Cannot add pairings to an archived tournament.', - CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT: 'Cannot add pairings to a draft tournament.', - CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT: 'Cannot add pairings to a tournament that hasn\'t started yet.', - // Tournament Lifecycle CANNOT_CLOSE_ROUND_ON_ARCHIVED_TOURNAMENT: 'Cannot close a round on an archived tournament.', CANNOT_CLOSE_ROUND_ON_DRAFT_TOURNAMENT: 'Cannot close a round on a tournament which is still a draft.', @@ -78,6 +73,18 @@ export const errors = { // Pairings NO_VALID_PAIRINGS_POSSIBLE: 'No valid pairing result possible.', NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_REPEAT: 'No valid pairing result possible without allowing a repeat.', + CANNOT_ADD_EMPTY_PAIRINGS_LIST: 'Cannot add an empty list of pairings.', + CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR: 'Cannot add pairing for competitor who is already paired.', + CANNOT_ADD_PAIRING_FOR_INACTIVE_COMPETITOR: 'Cannot add pairing for competitor which is not checked in.', + CANNOT_ADD_PAIRING_FOR_MISSING_COMPETITOR: 'Cannot add pairing for a competitor which does not exist.', + CANNOT_ADD_PAIRING_FOR_OVER_STRENGTH_COMPETITOR: 'Cannot add pairing for competitor which is over strength.', + CANNOT_ADD_PAIRING_FOR_UNDER_STRENGTH_COMPETITOR: 'Cannot add pairing for competitor which is under strength.', + CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT: 'Cannot add pairings to an archived tournament.', + CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT: 'Cannot add pairings to a draft tournament.', + CANNOT_ADD_PAIRINGS_TO_IN_PROGRESS_ROUND: 'Cannot add pairings while round is already in-progress.', + CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT: 'Cannot add pairings to a tournament that hasn\'t started yet.', + CANNOT_ADD_TOO_MANY_PAIRINGS: 'Cannot add more pairings than the tournament is set-up for.', + TOURNAMENT_ALREADY_HAS_PAIRINGS_FOR_ROUND: 'Tournament already has pairings for this round.', }; export function getErrorMessage(code: keyof typeof errors): { message: string, code: string } { diff --git a/convex/tournamentPairings.ts b/convex/tournamentPairings.ts index b5929185..0b034b41 100644 --- a/convex/tournamentPairings.ts +++ b/convex/tournamentPairings.ts @@ -1,4 +1,4 @@ -import { query } from './_generated/server'; +import { mutation, query } from './_generated/server'; import * as model from './_model/tournamentPairings'; export const getTournamentPairing = query({ @@ -20,3 +20,8 @@ export const getActiveTournamentPairingsByUser = query({ args: model.getActiveTournamentPairingsByUserArgs, handler: model.getActiveTournamentPairingsByUser, }); + +export const createTournamentPairings = mutation({ + args: model.createTournamentPairingsArgs, + handler: model.createTournamentPairings, +}); diff --git a/convex/tournaments.ts b/convex/tournaments.ts index 3f0bba91..2de224de 100644 --- a/convex/tournaments.ts +++ b/convex/tournaments.ts @@ -36,9 +36,9 @@ export const deleteTournament = mutation({ handler: model.deleteTournament, }); -export const closeTournamentRound = mutation({ - args: model.closeTournamentRoundArgs, - handler: model.closeTournamentRound, +export const endTournamentRound = mutation({ + args: model.endTournamentRoundArgs, + handler: model.endTournamentRound, }); export const endTournament = mutation({ @@ -46,9 +46,9 @@ export const endTournament = mutation({ handler: model.endTournament, }); -export const openTournamentRound = mutation({ - args: model.openTournamentRoundArgs, - handler: model.openTournamentRound, +export const startTournamentRound = mutation({ + args: model.startTournamentRoundArgs, + handler: model.startTournamentRound, }); export const publishTournament = mutation({ diff --git a/package-lock.json b/package-lock.json index 517d70a6..56300639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "convex": "^1.19.2", "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", - "edmonds-blossom": "^1.0.0", "embla-carousel-react": "^8.5.2", "fast-deep-equal": "^3.1.3", "flag-icons": "^7.2.3", @@ -84,7 +83,6 @@ "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/react": "^16.2.0", - "@types/edmonds-blossom": "^1.0.4", "@types/image-blob-reduce": "^4.1.4", "@types/luxon": "^3.4.2", "@types/node": "^22.13.5", @@ -7779,13 +7777,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/edmonds-blossom": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/edmonds-blossom/-/edmonds-blossom-1.0.4.tgz", - "integrity": "sha512-fqvPg7o20+XDGsx6UrzKe9ZWidWy2GJxw9L4C0i/wwueEwwUbxmo7eDcrTAvj2568ZRYobauAJMYjCCNlQsYiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -9843,12 +9834,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/edmonds-blossom": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/edmonds-blossom/-/edmonds-blossom-1.0.0.tgz", - "integrity": "sha512-wz18RgLg21nW4afc80d080fZuAjiaePfSoHje56aOiH8mO6O5Mc/VAv7s8bCBJkxsks37e0cYTS0dNirsQ4/rg==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.150", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.150.tgz", diff --git a/package.json b/package.json index e9cc563f..d961a5d3 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "convex": "^1.19.2", "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", - "edmonds-blossom": "^1.0.0", "embla-carousel-react": "^8.5.2", "fast-deep-equal": "^3.1.3", "flag-icons": "^7.2.3", @@ -100,7 +99,6 @@ "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/react": "^16.2.0", - "@types/edmonds-blossom": "^1.0.4", "@types/image-blob-reduce": "^4.1.4", "@types/luxon": "^3.4.2", "@types/node": "^22.13.5", diff --git a/src/api.ts b/src/api.ts index 0647faf2..f38f4fd8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -36,9 +36,9 @@ export { // Tournament Pairings export { + type ShallowTournamentPairing, type TournamentPairingDeep as TournamentPairing, type TournamentPairingId, - type UnassignedTournamentPairing, } from '../convex/_model/tournamentPairings'; // Tournament Timers diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts index 98aacbbf..29d02da2 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts +++ b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts @@ -6,4 +6,4 @@ type ConfirmationDialogData = { onConfirm: () => void; }; -export const useConfirmationDialog = (id: string) => useModal(id); +export const useConfirmationDialog = (id?: string) => useModal(id); diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.module.scss b/src/components/ConfirmationDialog/ConfirmationDialog.module.scss index e7fd3634..7559bd0a 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.module.scss +++ b/src/components/ConfirmationDialog/ConfirmationDialog.module.scss @@ -5,54 +5,11 @@ @use "/src/style/variables"; .ConfirmationDialog { - &_WarningsList { - @include flex.column($gap: 1rem); + &_Body { + @include flex.column; - padding: 0 var(--container-padding-x); - } - - &_WarningBlurb { - @include text.ui; - @include corners.normal; - @include borders.warning; - - display: grid; - grid-template-areas: - "icon header" - ". body"; - grid-template-columns: 1rem 1fr; - grid-template-rows: auto auto; - row-gap: 0.25rem; - column-gap: 0.5rem; - - padding: 1rem; - - color: var(--text-color-warning); - - background-color: var(--card-bg-warning); - - &_Icon { - grid-area: icon; - width: 1rem; - height: 1rem; + &[data-padding="true"] { + padding: 0 var(--container-padding-x); } - - &_Header { - grid-area: header; - } - - &_Body { - @include flex.column($gap: 0.5rem); - - grid-area: body; - - p { - color: inherit; - } - } - } - - &_Children { - padding: 0 var(--container-padding-x); } } diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 248c9608..635231a6 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,6 +1,5 @@ import { ReactNode } from 'react'; import clsx from 'clsx'; -import { TriangleAlert } from 'lucide-react'; import { Button } from '~/components/generic/Button'; import { @@ -23,8 +22,8 @@ export interface ConfirmationDialogProps { intent?: ElementIntent; onConfirm?: () => void; title: string; - warnings?: ReactNode[]; disabled?: boolean; + disablePadding?: boolean; } export const ConfirmationDialog = ({ @@ -35,8 +34,8 @@ export const ConfirmationDialog = ({ intent = 'default', onConfirm, title, - warnings = [], disabled = false, + disablePadding = false, }: ConfirmationDialogProps): JSX.Element => { const { close, data } = useConfirmationDialog(id); const handleConfirm = (): void => { @@ -57,26 +56,9 @@ export const ConfirmationDialog = ({ {data?.description || description} )} - {warnings.length > 0 && ( -
- {warnings.map((warning, i) => ( -
- -

- Warning -

-
- {warning} -
-
- ))} -
- )} - {children && ( -
- {children} -
- )} +
+ {children} +
- + <> + + + + + ); }; diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss new file mode 100644 index 00000000..62aea60f --- /dev/null +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss @@ -0,0 +1,5 @@ +@use "/src/style/flex"; + +.ConfirmConfigureRoundDialog { + @include flex.column; +} diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx new file mode 100644 index 00000000..546fe582 --- /dev/null +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx @@ -0,0 +1,95 @@ +import { + forwardRef, + ReactNode, + useImperativeHandle, + useMemo, +} from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; +import clsx from 'clsx'; + +import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; +import { Warning } from '~/components/generic/Warning'; +import { toast } from '~/components/ToastProvider'; +import { useTournament } from '~/components/TournamentProvider'; +import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; +import { PATHS } from '~/settings'; +import { getTournamentCompetitorDisplayName } from '~/utils/common/getTournamentCompetitorDisplayName'; +import { getWarnings, sortCompetitorsByActive } from './ConfirmConfigureRoundDialog.utils'; + +import styles from './ConfirmConfigureRoundDialog.module.scss'; + +export interface ConfirmConfigureRoundDialogProps { + className?: string; +} + +export interface ConfirmConfigureRoundDialogHandle { + open: () => void; +} + +export const ConfirmConfigureRoundDialog = forwardRef(({ + className, +}: ConfirmConfigureRoundDialogProps, ref): JSX.Element => { + const tournament = useTournament(); + const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ + tournamentId: tournament._id, + }); + const navigate = useNavigate(); + const { id, open } = useConfirmationDialog(); + const proceed = (): void => { + navigate(generatePath(PATHS.tournamentPairings, { id: tournament._id })); + }; + + const { active: activeCompetitors } = sortCompetitorsByActive(tournamentCompetitors ?? []); + const warnings: ReactNode[] = useMemo(() => getWarnings(tournament, tournamentCompetitors ?? []), [ + tournament, + tournamentCompetitors, + ]); + + useImperativeHandle(ref, () => ({ + open: () => { + if (activeCompetitors.length < 2) { + return toast.error('Cannot Configure Round', { + description: 'Please ensure at least 2 competitors are active.', + }); + } + if (activeCompetitors.length > tournament.maxCompetitors) { + return toast.error('Cannot Configure Round', { + description: `There are too many active competitors. Please disable ${activeCompetitors.length - tournament.maxCompetitors} to proceed.`, + }); + } + for (const competitor of activeCompetitors) { + const activePlayers = competitor.players.filter(({ active }) => active); + if (activePlayers.length > tournament.competitorSize) { + return toast.error('Cannot Configure Round', { + description: `${getTournamentCompetitorDisplayName(competitor)} has too many active players.`, + }); + } + if (activePlayers.length < tournament.competitorSize) { + return toast.error('Cannot Configure Round', { + description: `${getTournamentCompetitorDisplayName(competitor)} has too few active players.`, + }); + } + } + + if (warnings.length) { + open(); + } else { + proceed(); + } + }, + })); + return ( + + {warnings.map((warning, i) => ( + + {warning} + + ))} + + ); +}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx similarity index 66% rename from src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx rename to src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx index 651dfeb0..c7ee414b 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx @@ -15,23 +15,22 @@ export const sortCompetitorsByActive = (tournamentCompetitors: TournamentCompeti ); export const getWarnings = (tournament: Tournament, tournamentCompetitors: TournamentCompetitor[]): ReactNode[] => { - const round = (tournament.currentRound ?? 0) + 1; - const sortedCompetitors = sortCompetitorsByActive(tournamentCompetitors); - const inactiveCompetitorCount = sortedCompetitors.inactive.length ?? 0; + const round = (tournament.lastRound ?? -1) + 2; + const { active, inactive } = sortCompetitorsByActive(tournamentCompetitors); const warnings: ReactNode[] = []; - if (sortedCompetitors.inactive.length > 0) { + if (inactive.length > 0) { warnings.push( <>

- {`The following ${tournament.useTeams ? ('team' + (inactiveCompetitorCount > 1 ? 's are' : ' is')) : 'player(s)'} not listed as checked in and will not be included in the pairing process for round ${round}.`} + {`The following ${tournament.useTeams ? ('team' + ((inactive.length ?? 0) > 1 ? 's are' : ' is')) : 'player(s)'} not listed as checked in and will not be included in the pairing process for round ${round}.`}

- {sortedCompetitors?.inactive.map((tournamentCompetitor) => ( + {inactive.map((tournamentCompetitor) => ( ))} , ); } - if (sortedCompetitors.active.length % 2) { + if (active.length % 2) { warnings.push(

{`There is an odd number of competitors, so one competitor will remain unpaired. As tournament organizer, you will need to submit match results for the ${tournament.useTeams ? 'team' : 'player'} with a bye, with the desired outcome.`} diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts new file mode 100644 index 00000000..61bf2d13 --- /dev/null +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts @@ -0,0 +1,5 @@ +export { + ConfirmConfigureRoundDialog, + type ConfirmConfigureRoundDialogHandle, + type ConfirmConfigureRoundDialogProps, +} from './ConfirmConfigureRoundDialog'; diff --git a/src/components/TournamentContextMenu/index.ts b/src/components/TournamentContextMenu/index.ts index d1c00880..1ce08722 100644 --- a/src/components/TournamentContextMenu/index.ts +++ b/src/components/TournamentContextMenu/index.ts @@ -1 +1,5 @@ +export { + ConfirmConfigureRoundDialog, + type ConfirmConfigureRoundDialogHandle, +} from './components/ConfirmConfigureRoundDialog'; export { TournamentContextMenu } from './TournamentContextMenu'; diff --git a/src/components/TournamentPairingRow/TournamentPairingRow.tsx b/src/components/TournamentPairingRow/TournamentPairingRow.tsx index ff9e5e23..0681a45a 100644 --- a/src/components/TournamentPairingRow/TournamentPairingRow.tsx +++ b/src/components/TournamentPairingRow/TournamentPairingRow.tsx @@ -1,11 +1,7 @@ import { useWindowWidth } from '@react-hook/window-size/throttled'; import clsx from 'clsx'; -import { - DraftTournamentPairing, - TournamentPairing, - UnassignedTournamentPairing, -} from '~/api'; +import { DraftTournamentPairing, TournamentPairing } from '~/api'; import { IdentityBadge } from '~/components/IdentityBadge'; import { MOBILE_BREAKPOINT } from '~/settings'; import { getIdentityBadgeProps } from './TournamentPairingRow.utils'; @@ -13,7 +9,7 @@ import { getIdentityBadgeProps } from './TournamentPairingRow.utils'; import styles from './TournamentPairingRow.module.scss'; export interface TournamentPairingRowProps { - pairing?: TournamentPairing | DraftTournamentPairing | UnassignedTournamentPairing; + pairing?: TournamentPairing | DraftTournamentPairing; loading?: boolean; className?: string; } diff --git a/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx b/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx index f4f9324f..9e60cfff 100644 --- a/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx +++ b/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx @@ -1,21 +1,10 @@ import { ChevronRight } from 'lucide-react'; -import { - DraftTournamentPairing, - TournamentPairing, - UnassignedTournamentPairing, -} from '~/api'; +import { DraftTournamentPairing, TournamentPairing } from '~/api'; import { IdentityBadgeProps } from '~/components/IdentityBadge'; +import { TournamentPairingFormItem } from '~/pages/TournamentPairingsPage/TournamentPairingsPage.schema'; -export function isDraftPairing(pairing: unknown): pairing is DraftTournamentPairing { - return Array.isArray(pairing) && - pairing.length > 0 && - typeof pairing[0] === 'object' && - pairing[0] !== null && - 'id' in pairing[0]; -} - -export function isUnassignedPairingInput(pairing: unknown): pairing is UnassignedTournamentPairing { +export function isUnassignedPairingInput(pairing: unknown): pairing is DraftTournamentPairing { return typeof pairing === 'object' && pairing !== null && 'tournamentCompetitor0Id' in pairing; @@ -28,21 +17,8 @@ export function isTournamentPairing(pairing: unknown): pairing is TournamentPair } export const getIdentityBadgeProps = ( - pairing?: TournamentPairing | DraftTournamentPairing | UnassignedTournamentPairing, + pairing?: TournamentPairing | TournamentPairingFormItem | DraftTournamentPairing, ): [Partial, Partial] => { - if (isDraftPairing(pairing)) { - if (pairing[1]) { - return [ - { competitorId: pairing[0].id }, - { competitorId: pairing[1].id }, - ]; - } - return [ - { competitorId: pairing[0].id }, - { placeholder: { displayName: 'Bye', icon: } }, - ]; - } - if (isUnassignedPairingInput(pairing)) { if (pairing.tournamentCompetitor1Id) { return [ diff --git a/src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss b/src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss deleted file mode 100644 index aa68f25c..00000000 --- a/src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.Draggable { - cursor: grab; - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-user-select: none; - user-select: none; - - outline: none; - - -webkit-touch-callout: none; -} diff --git a/src/components/TournamentPairingsGrid/Draggable/Draggable.tsx b/src/components/TournamentPairingsGrid/Draggable/Draggable.tsx deleted file mode 100644 index 935ebe9d..00000000 --- a/src/components/TournamentPairingsGrid/Draggable/Draggable.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ReactNode } from 'react'; -import { UniqueIdentifier, useDraggable } from '@dnd-kit/core'; -import { CSS } from '@dnd-kit/utilities'; - -import styles from './Draggable.module.scss'; - -export interface DraggableProps { - id: UniqueIdentifier; - children: ReactNode; - isOverlay?: boolean; -} - -export const Draggable = ({ - id, - children, -}: DraggableProps): JSX.Element => { - const { - attributes, - listeners, - setNodeRef, - transform, - isDragging, - } = useDraggable({ id }); - const style = { - transform: CSS.Translate.toString(transform), - opacity: isDragging ? 0 : 1, - // cursor: isDragging ? 'grabbing' : 'grab', - }; - return ( -

- {children} -
- ); -}; diff --git a/src/components/TournamentPairingsGrid/Draggable/index.ts b/src/components/TournamentPairingsGrid/Draggable/index.ts deleted file mode 100644 index 0b199a17..00000000 --- a/src/components/TournamentPairingsGrid/Draggable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Draggable } from './Draggable'; diff --git a/src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss b/src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss deleted file mode 100644 index 96142f44..00000000 --- a/src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss +++ /dev/null @@ -1,43 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -// TODO: Move to proper variables -@import "@radix-ui/colors/gray-alpha.css"; -@import "@radix-ui/colors/gray-dark.css"; -@import "@radix-ui/colors/tomato-alpha.css"; -@import "@radix-ui/colors/tomato-dark.css"; - -.Droppable { - @include corners.normal; - - // @include flex.stretchy; - - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-user-select: none; - user-select: none; - - padding: 0.25rem; - - background-color: var(--gray-a2); - outline: none; - - transition: background-color 250ms cubic-bezier(0.18, 0.67, 0.6, 1.22); - - -webkit-touch-callout: none; - - &[data-over="true"] { - background-color: var(--gray-a3); - } - - &[data-invalid="true"] { - background-color: var(--tomato-a4); - - &[data-over="true"] { - background-color: var(--tomato-a6); - } - } -} diff --git a/src/components/TournamentPairingsGrid/Droppable/Droppable.tsx b/src/components/TournamentPairingsGrid/Droppable/Droppable.tsx deleted file mode 100644 index 4a871aa1..00000000 --- a/src/components/TournamentPairingsGrid/Droppable/Droppable.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { CSSProperties } from 'react'; -import { UniqueIdentifier, useDroppable } from '@dnd-kit/core'; -import clsx from 'clsx'; - -import styles from './Droppable.module.scss'; - -export interface DroppableProps { - children: React.ReactNode; - className?: string; - id: UniqueIdentifier; - invalid?: boolean; - style?: CSSProperties; -} - -export const Droppable = ({ - children, - className, - id, - invalid, - style = {}, -}: DroppableProps): JSX.Element => { - const { isOver, setNodeRef } = useDroppable({ id }); - return ( -
- {children} -
- ); -}; diff --git a/src/components/TournamentPairingsGrid/Droppable/index.ts b/src/components/TournamentPairingsGrid/Droppable/index.ts deleted file mode 100644 index d9160b8a..00000000 --- a/src/components/TournamentPairingsGrid/Droppable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Droppable } from './Droppable'; diff --git a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss b/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss deleted file mode 100644 index 770e23ce..00000000 --- a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss +++ /dev/null @@ -1,36 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairableCompetitorCard { - --rank-size: 2.5rem; - - @include variants.outlined($hover: false); - @include shadows.surface; - @include corners.normal; - - display: grid; - grid-template-areas: "identity rank"; - grid-template-columns: 1fr var(--rank-size); - padding: calc(0.75rem - 1px); - - &_Identity { - grid-area: identity; - } - - &_Rank { - @include text.ui; - @include flex.centered; - - grid-area: rank; - - width: var(--rank-size); - - font-size: 2rem; - font-weight: 300; - line-height: var(--rank-size); - } -} diff --git a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx b/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx deleted file mode 100644 index e9221047..00000000 --- a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { - ComponentPropsWithoutRef, - ElementRef, - forwardRef, -} from 'react'; -import clsx from 'clsx'; -import { motion } from 'framer-motion'; - -import { TournamentCompetitorId } from '~/api'; -import { IdentityBadge } from '~/components/IdentityBadge'; -import { useGetTournamentCompetitor } from '~/services/tournamentCompetitors'; - -import styles from './PairableCompetitorCard.module.scss'; - -type PairableCompetitorCardRef = ElementRef; -type PairableCompetitorCardProps = ComponentPropsWithoutRef & { - competitorId: TournamentCompetitorId; - rank: number | null; - className?: string; -}; - -export const PairableCompetitorCard = forwardRef(({ - className, - competitorId, - rank, - ...props -}, ref) => { - const { data: competitor, loading } = useGetTournamentCompetitor({ id: competitorId }); - return ( - - -
- {rank === null ? '-' : rank + 1} -
-
- ); -}); diff --git a/src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts b/src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts deleted file mode 100644 index b9d15cb0..00000000 --- a/src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PairableCompetitorCard } from './PairableCompetitorCard'; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss deleted file mode 100644 index 23aeb6f3..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss +++ /dev/null @@ -1,44 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairingsGridRow { - --indicator-size: 1.5rem; - - display: grid; - grid-template-columns: 1fr var(--indicator-size) 1fr; - flex-grow: 0; - gap: 0.5rem; - - &_Droppable { - height: 4.5rem; - } - - &_Indicator { - grid-column: 2/3; - grid-row: 1/2; - place-self: center center; - - &[data-valid="true"] { - color: var(--text-color-success); - } - - &[data-valid="false"] { - color: var(--text-color-negative); - } - - div { - @include flex.centered; - - width: var(--indicator-size); - height: var(--indicator-size); - } - - svg { - width: var(--indicator-size); - } - } -} diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx deleted file mode 100644 index 59f375bd..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion'; -import { CircleCheck, CircleX } from 'lucide-react'; - -import { TournamentCompetitorRanked } from '~/api'; -import { Draggable } from '../Draggable'; -import { Droppable } from '../Droppable/Droppable'; -import { PairableCompetitorCard } from '../PairableCompetitorCard'; -import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; -import { checkDraftPairingIsValid } from './PairingsGridRow.utils'; - -import styles from './PairingsGridRow.module.scss'; - -const iconVariants = { - initial: { opacity: 0, scale: 0.5 }, - animate: { opacity: 1, scale: 1, transition: { duration: 0.1 } }, - exit: { opacity: 0, scale: 0.5, transition: { duration: 0.05 } }, -}; - -export interface PairingsGridRowProps { - index: number; - pairing?: Partial; - activeCompetitor?: TournamentCompetitorRanked | null; -} - -export const PairingsGridRow = ({ - index, - pairing, - activeCompetitor, -}: PairingsGridRowProps): JSX.Element => { - const isValid = pairing && pairing[0] && pairing[1] ? checkDraftPairingIsValid(pairing) : undefined; - return ( -
-
- - {isValid && ( - - - - )} - {isValid === false && ( - - - - )} - -
- {[0, 1].map((j) => { - const competitor = pairing && pairing[j]; - const slotId = `${index}_${j}`; - const oppositeCompetitor = j === 0 ? (pairing && pairing[1]) : (pairing && pairing[0]); - const invalid = !!(activeCompetitor && oppositeCompetitor) && activeCompetitor.opponentIds.includes(oppositeCompetitor.id); - const style = { - gridColumn: j === 0 ? '1 / 2' : '3 / 4', - gridRow: '1 / 2', - }; - return ( - - {competitor && ( - - - - )} - - ); - })} -
- ); -}; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts deleted file mode 100644 index a13691d4..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; - -/** - * Checks if a DraftPairing is valid. - * - * @param pairing - DraftPairing to check - * @returns True if the resulting TournamentPairing would be valid, false if not - */ -export const checkDraftPairingIsValid = ( - pairing: Partial, -): boolean => { - if (!pairing || !pairing[0] || !pairing[1]) { - return false; - } - if (pairing[0].id === pairing[1].id) { - return false; - } - if (pairing[0].opponentIds.includes(pairing[1].id)) { - return false; - } - if (pairing[1].opponentIds.includes(pairing[0].id)) { - return false; - } - return true; -}; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/index.ts b/src/components/TournamentPairingsGrid/PairingsGridRow/index.ts deleted file mode 100644 index 392bd647..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { PairingsGridRowProps } from './PairingsGridRow'; -export { PairingsGridRow } from './PairingsGridRow'; diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss deleted file mode 100644 index 71460e80..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss +++ /dev/null @@ -1,55 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairingStep { - @include flex.column; - @include borders.normal; - @include shadows.surface; - @include corners.normal; - @include flex.stretchy; - - padding: var(--container-padding-y) var(--container-padding-x); - background: var(--card-bg); - - &_PairingMethodSection { - @include flex.row; - } -} - -.PairingsGrid { - display: grid; - grid-template-columns: 1fr 1.5rem 1fr 1fr; - gap: 1rem; -} - -.PairedSection { - @include flex.column($gap: 0.5rem); - - grid-column: 1 / 4; -} - -.UnpairedSection { - @include flex.column($gap: 0.5rem); - - grid-column: 4 / 5; -} - -.DragContent { - @include borders.normal; - @include shadows.surface; - @include corners.normal; - @include flex.stretchy; - - height: 5rem; - padding: calc(1rem - 1px); -} - -.UnpairedPool { - @include flex.column($gap: 0.5rem); - - flex: 1 auto; -} diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx deleted file mode 100644 index 6d8b840b..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useState, -} from 'react'; -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - rectIntersection, -} from '@dnd-kit/core'; -import { restrictToWindowEdges } from '@dnd-kit/modifiers'; -import isEqual from 'fast-deep-equal'; -import { AnimatePresence } from 'framer-motion'; - -import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; -import { Label } from '~/components/generic/Label'; -import { useTournament } from '~/components/TournamentProvider'; -import { Draggable } from './Draggable'; -import { Droppable } from './Droppable'; -import { PairableCompetitorCard } from './PairableCompetitorCard'; -import { PairingsGridRow } from './PairingsGridRow'; -import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; -import { - buildGridState, - buildPairingResult, - convertPairingResultToCompetitorList, -} from './TournamentPairingsGrid.utils'; - -import styles from './TournamentPairingsGrid.module.scss'; - -const grabMotionDuration = 0.150; -const grabMotionInitial = { - scale: 1, - boxShadow: '0px 0px 0px rgba(0, 0, 0, 0)', -}; -const grabMotionAnimate = { - scale: 1.05, - boxShadow: '0px 8px 20px rgba(0, 0, 0, 0.2)', -}; -const grabAnimationProps = { - initial: grabMotionInitial, - animate: grabMotionAnimate, - exit: grabMotionInitial, - transition: { duration: grabMotionDuration }, -}; - -export interface TournamentPairingsGridProps { - defaultValue?: DraftTournamentPairing[]; - onChange: (value: DraftTournamentPairing[]) => void; -} - -export interface TournamentPairingsGridHandle { - reset: (pairings: DraftTournamentPairing[]) => void; - isDirty: boolean; -} - -export const TournamentPairingsGrid = forwardRef(({ - defaultValue, - onChange, -}: TournamentPairingsGridProps, ref): JSX.Element => { - const tournament = useTournament(); - const pairingIndexes = Array.from({ length: Math.ceil(tournament.maxCompetitors / 2) }, (_, i) => i); - - // Store competitors with their opponentIds so we can check pairing validity: - const competitors = useMemo(() => convertPairingResultToCompetitorList(defaultValue), [defaultValue]); - - // State: - const [activeCompetitorId, setActiveCompetitorId] = useState(null); - const [gridState, setGridState] = useState(null); - - // Set internal state from parent: - useEffect(() => { - if (defaultValue && !gridState) { - setGridState(buildGridState(defaultValue)); - } - }, [defaultValue, gridState]); - - const pairingResult = useMemo(() => buildPairingResult(competitors, gridState), [competitors, gridState]); - const isDirty = !isEqual(defaultValue, pairingResult); - - // Emit change to parent components: - useEffect(() => { - onChange(pairingResult); - }, [pairingResult, onChange]); - - // Allow parent to reset and track dirty state: - useImperativeHandle(ref, () => ({ - reset: (pairings: DraftTournamentPairing[]): void => setGridState(buildGridState(pairings)), - pairingResult, - isDirty, - })); - - useEffect(() => { - document.body.style.cursor = activeCompetitorId ? 'grabbing' : 'default'; - return () => { - document.body.style.cursor = 'default'; - }; - }, [activeCompetitorId]); - - const handleDragStart = ({ active }: DragStartEvent) => { - if (active) { - setActiveCompetitorId(active.id as TournamentCompetitorId); - } - }; - - const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || !gridState) { - return; - } - setActiveCompetitorId(null); - setGridState(Object.entries(gridState).map(([competitorId, slotId]) => { - - // If this ID is the active one, we're dragging it. Set it's slotID to 'over': - if (competitorId === active.id) { - return [competitorId, over.id]; - } - - // If this slot is the target, move its competitor to 'unpaired': - if (slotId === over.id) { - return [competitorId, 'unpaired']; - } - - // Otherwise do nothing: - return [competitorId, slotId]; - }).reduce((acc, [pairingCompetitorId, slotId]) => ({ - ...acc, - [pairingCompetitorId as TournamentCompetitorId]: slotId, - }), {})); - }; - - const unpairedCompetitors = competitors.filter((c) => gridState && gridState[c.id] === 'unpaired'); - const activeCompetitor = competitors.find((c) => c.id === activeCompetitorId); - const gridStatePivoted = Object.entries(gridState ?? {}).reduce((acc, [competitorId, slotId]) => ({ - ...acc, - [slotId]: competitors.find((c) => c.id === competitorId), - }), {} as Record); - - return ( - -
-
- - {pairingIndexes.map((i) => { - const pairing: DraftTournamentPairing = [ - gridStatePivoted[`${i}_0`] ?? null, - gridStatePivoted[`${i}_1`] ?? null, - ]; - return ( - - ); - })} -
-
- - - - {unpairedCompetitors.map((competitor) => ( - - - - ))} - - -
-
- - {activeCompetitor && ( - - - - - - )} - -
- ); -}); diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts deleted file mode 100644 index daf214a1..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; - -export type PairingsGridState = Record; - -export type DraftTournamentPairing = [TournamentCompetitorRanked | null, TournamentCompetitorRanked | null]; diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts deleted file mode 100644 index 4baa4169..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; -import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; - -export const convertPairingResultToCompetitorList = (draftPairings?: DraftTournamentPairing[]): TournamentCompetitorRanked[] => { - if (!draftPairings) { - return []; - } - const competitors = new Set(); - draftPairings.forEach((pairing) => { - if (pairing[0]) { - competitors.add(pairing[0]); - } - if (pairing[1]) { - competitors.add(pairing[1]); - } - }); - return Array.from(competitors); -}; - -export const buildGridState = (draftPairings?: DraftTournamentPairing[]): Record => { - if (!draftPairings) { - return {}; - } - return draftPairings.reduce((acc, pairing, i) => { - if (pairing[0] && !pairing[1]) { - return { - ...acc, - [pairing[0].id]: 'unpaired', - }; - } - if (!pairing[0] && pairing[1]) { - return { - ...acc, - [pairing[1].id]: 'unpaired', - }; - } - if (pairing[0] && pairing[1]) { - return { - ...acc, - [pairing[0].id]: `${i}_0`, - [pairing[1].id]: `${i}_1`, - }; - } - return acc; - }, {} as Record); -}; - -export const buildPairingResult = (competitors: TournamentCompetitorRanked[], state: PairingsGridState | null): DraftTournamentPairing[] => { - if (!competitors?.length || !state || !Object.keys(state).length) { - return []; - } - const statefulCompetitors = competitors.map((competitor) => ({ - competitor, - slotId: state[competitor.id], - })); - - const pairings: DraftTournamentPairing[] = []; - - // Add all full pairings: - const pairedCompetitors = statefulCompetitors.filter(({ slotId }) => slotId !== 'unpaired'); - for (const { competitor, slotId } of pairedCompetitors) { - const [i,j] = slotId.split('_').map((i) => parseInt(i, 10)); - if (!pairings[i]) { - pairings[i] = [null, null] as unknown as DraftTournamentPairing; - } - pairings[i][j] = competitor; - } - - // Add all partial pairings: - const unpairedCompetitors = statefulCompetitors.filter(({ slotId }) => slotId === 'unpaired'); - for (const { competitor } of unpairedCompetitors) { - pairings.push([competitor, null]); - } - - return pairings; -}; diff --git a/src/components/TournamentPairingsGrid/index.ts b/src/components/TournamentPairingsGrid/index.ts deleted file mode 100644 index 0a628319..00000000 --- a/src/components/TournamentPairingsGrid/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - TournamentPairingsGrid, - type TournamentPairingsGridHandle, - type TournamentPairingsGridProps, -} from './TournamentPairingsGrid'; -export type { DraftTournamentPairing } from './TournamentPairingsGrid.types'; diff --git a/src/components/generic/InfoPopover/InfoPopover.module.scss b/src/components/generic/InfoPopover/InfoPopover.module.scss index f6102bf9..e8e43406 100644 --- a/src/components/generic/InfoPopover/InfoPopover.module.scss +++ b/src/components/generic/InfoPopover/InfoPopover.module.scss @@ -29,6 +29,7 @@ will-change: transform, opacity; + max-width: 20rem; padding: 0.625rem 0.75rem 0.5rem; color: var(--primary-default-text); diff --git a/src/components/generic/InfoPopover/InfoPopover.tsx b/src/components/generic/InfoPopover/InfoPopover.tsx index 23f77941..011b8bc3 100644 --- a/src/components/generic/InfoPopover/InfoPopover.tsx +++ b/src/components/generic/InfoPopover/InfoPopover.tsx @@ -10,6 +10,7 @@ export interface InfoPopoverProps { className?: string; content: string; disableAutoHide?: boolean; + orientation?: 'vertical' | 'horizontal'; } export const InfoPopover = ({ @@ -18,6 +19,7 @@ export const InfoPopover = ({ className, content, disableAutoHide = false, + orientation = 'vertical', }: InfoPopoverProps): JSX.Element => { const [open, setOpen] = useState(false); const handleOpenChange = (open: boolean) => { @@ -34,7 +36,7 @@ export const InfoPopover = ({ {children} - + {content} diff --git a/src/components/generic/Pulsar/Pulsar.module.scss b/src/components/generic/Pulsar/Pulsar.module.scss new file mode 100644 index 00000000..3d254ece --- /dev/null +++ b/src/components/generic/Pulsar/Pulsar.module.scss @@ -0,0 +1,81 @@ +@use "/src/style/variables"; + +.Pulsar { + pointer-events: auto; + + position: relative; + transform: scale(0); + + overflow: visible; + + border-radius: 50%; + + transition: transform 300ms ease; + + &::before { + content: ""; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: 40px; + height: 40px; + } + + &[data-visible="true"] { + transform: scale(1); + } + + &[data-visible="false"] { + transform: scale(0); + } + + &[data-color="red"] { + background-color: var(--bg-red); + } + + &[data-color="blue"] { + background-color: var(--bg-blue); + } + + &[data-color="green"] { + background-color: var(--bg-green); + } + + &[data-color="yellow"] { + background-color: var(--bg-yellow); + } +} + +.Pulse { + pointer-events: none; + content: ""; + + position: absolute; + z-index: -1; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + opacity: 0.5; + background-color: inherit; + border-radius: 50%; + + animation: pulse 1.5s ease-out infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.5; + } + + 100% { + transform: scale(3); /* roughly 0.5rem outward */ + opacity: 0; + } +} diff --git a/src/components/generic/Pulsar/Pulsar.tsx b/src/components/generic/Pulsar/Pulsar.tsx new file mode 100644 index 00000000..a25fcedb --- /dev/null +++ b/src/components/generic/Pulsar/Pulsar.tsx @@ -0,0 +1,38 @@ +import { forwardRef, MouseEvent } from 'react'; + +import styles from './Pulsar.module.scss'; + +interface PulsarProps { + size?: number; + color?: 'red' | 'yellow' | 'green' | 'blue'; + visible?: boolean; + pulse?: boolean; + onClick?: (e: MouseEvent) => void; +} + +export const Pulsar = forwardRef(({ + size = 8, + color = 'blue', + visible = true, + pulse = true, + onClick, +}, ref) => ( +
{ + // e.preventDefault(); + onClick(e); + } : undefined} + style={{ + width: `${size}px`, + height: `${size}px`, + }} + > + {pulse && } +
+)); + +Pulsar.displayName = 'Pulsar'; diff --git a/src/components/generic/Pulsar/index.ts b/src/components/generic/Pulsar/index.ts new file mode 100644 index 00000000..68aca94d --- /dev/null +++ b/src/components/generic/Pulsar/index.ts @@ -0,0 +1,3 @@ +export { + Pulsar, +} from './Pulsar'; diff --git a/src/components/generic/SortableGrid/SortableGrid.module.scss b/src/components/generic/SortableGrid/SortableGrid.module.scss new file mode 100644 index 00000000..ec5aac28 --- /dev/null +++ b/src/components/generic/SortableGrid/SortableGrid.module.scss @@ -0,0 +1,5 @@ +.Grid { + user-select: none; + display: grid; + gap: 0.5rem; +} diff --git a/src/components/generic/SortableGrid/SortableGrid.tsx b/src/components/generic/SortableGrid/SortableGrid.tsx new file mode 100644 index 00000000..4f08a8d7 --- /dev/null +++ b/src/components/generic/SortableGrid/SortableGrid.tsx @@ -0,0 +1,139 @@ +import { + CSSProperties, + ReactNode, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { + closestCenter, + defaultDropAnimationSideEffects, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arraySwap, + rectSwappingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import clsx from 'clsx'; + +import { SortableItem } from './components/SortableItem'; + +import styles from './SortableGrid.module.scss'; + +export interface SortableGridProps { + className?: string; + itemClassName?: string; + columns?: number; + items: UniqueIdentifier[]; + onChange: (items: UniqueIdentifier[]) => void; + renderItem: (id: UniqueIdentifier, state: { + activeId: UniqueIdentifier | null; + isActive: boolean; + isOverlay: boolean; + }) => ReactNode; +} + +export const SortableGrid = ({ + className, + itemClassName, + columns = 1, + items, + onChange, + renderItem, +}: SortableGridProps): JSX.Element => { + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { + // Disable smooth scrolling in Cypress automated tests + scrollBehavior: 'Cypress' in window ? 'auto' : undefined, + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const getIndex = (id: UniqueIdentifier): number => items.indexOf(id); + const activeIndex = activeId != null ? getIndex(activeId) : -1; + + const handleDragStart = ({ active }: DragStartEvent): void => { + if (!active) { + return; + } + setActiveId(active.id); + }; + const handleDragEnd = ({ over }: DragEndEvent): void => { + setActiveId(null); + if (over) { + const overIndex = getIndex(over.id); + if (activeIndex !== overIndex) { + const updated = arraySwap(items, activeIndex, overIndex); + onChange(updated); + } + } + }; + const handleDragCancel = (): void => { + setActiveId(null); + }; + + const style: CSSProperties = { + gridTemplateColumns: Array.from({ length: columns }).map((_) => '1fr').join(' '), + }; + + return ( + + +
+ {items.map((id) => ( + + {renderItem(id, { + activeId, + isActive: activeId === id, + isOverlay: false, + })} + + ))} +
+
+ {createPortal( + + {activeId != null ? ( + + {renderItem(activeId, { + activeId, + isActive: true, + isOverlay: true, + })} + + ) : null} + , + document.body, + )} +
+ ); +}; diff --git a/src/components/generic/SortableGrid/components/SortableItem.module.scss b/src/components/generic/SortableGrid/components/SortableItem.module.scss new file mode 100644 index 00000000..bd218ebc --- /dev/null +++ b/src/components/generic/SortableGrid/components/SortableItem.module.scss @@ -0,0 +1,80 @@ +@use "/src/style/shadows"; +@use "/src/style/variants"; +@use "/src/style/variables"; + +$box-shadow-down: 0 1px 2px 0 rgb(0 0 0 / 5%); +$box-shadow-lifted: + 0 4px 8px calc(1px / var(--scale-x, 1)) rgb(0 0 0 / 12%), + 0 12px 24px calc(2px / var(--scale-x, 1)) rgb(0 0 0 / 16%); + +.SortableItem { + &_Wrapper { + touch-action: manipulation; + + transform-origin: 0 0; + transform: + translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) + scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1)); + + display: flex; + + box-sizing: border-box; + + &[data-overlay="true"] { + --scale: 1.05; + + z-index: 999; + } + } + + &_Content { + @include variants.card; + + touch-action: manipulation; + cursor: grab; + + position: relative; + transform-origin: 50% 50%; + transform: scale(1); + + display: flex; + flex-grow: 1; + align-items: center; + + box-sizing: border-box; + + background-color: var(--card-bg); + outline: none; + box-shadow: $box-shadow-down; + + transition: box-shadow 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); + + -webkit-tap-highlight-color: transparent; + + &[data-ghost="true"] { + z-index: 0; + opacity: 0.5; + } + + &[data-overlay="true"] { + cursor: inherit; + transform: scale(1.05); + + // opacity: 1; + box-shadow: $box-shadow-lifted; + animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); + } + } +} + +@keyframes pop { + 0% { + transform: scale(1); + box-shadow: $box-shadow-down; + } + + 100% { + transform: scale(1.05); + box-shadow: $box-shadow-lifted; + } +} diff --git a/src/components/generic/SortableGrid/components/SortableItem.tsx b/src/components/generic/SortableGrid/components/SortableItem.tsx new file mode 100644 index 00000000..9af78655 --- /dev/null +++ b/src/components/generic/SortableGrid/components/SortableItem.tsx @@ -0,0 +1,78 @@ +import { + forwardRef, + memo, + ReactNode, + useEffect, +} from 'react'; +import { UniqueIdentifier } from '@dnd-kit/core'; +import { arraySwap, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import clsx from 'clsx'; + +import styles from './SortableItem.module.scss'; + +interface SortableItemProps { + className?: string; + id: UniqueIdentifier; + children: ReactNode; + overlay?: boolean; +} + +export const SortableItem = memo(forwardRef(({ + className, + id, + children, + overlay = false, + ...props +}, ref) => { + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ + id, + getNewIndex: ({ + id, + items, + activeIndex, + overIndex, + }) => arraySwap(items, activeIndex, overIndex).indexOf(id), + }); + + useEffect(() => { + if (!overlay) { + return; + } + document.body.style.cursor = 'grabbing'; + return () => { + document.body.style.cursor = ''; + }; + }, [overlay]); + + return ( +
+
+ {children} +
+
+ ); +})); diff --git a/src/components/generic/SortableGrid/components/index.ts b/src/components/generic/SortableGrid/components/index.ts new file mode 100644 index 00000000..15c074c2 --- /dev/null +++ b/src/components/generic/SortableGrid/components/index.ts @@ -0,0 +1,3 @@ +export { + SortableItem, +} from './SortableItem'; diff --git a/src/components/generic/SortableGrid/index.ts b/src/components/generic/SortableGrid/index.ts new file mode 100644 index 00000000..07a6f4e1 --- /dev/null +++ b/src/components/generic/SortableGrid/index.ts @@ -0,0 +1,3 @@ +export { + SortableGrid, +} from './SortableGrid'; diff --git a/src/components/generic/Table/Table.tsx b/src/components/generic/Table/Table.tsx index 26ad5e1f..d4c1960e 100644 --- a/src/components/generic/Table/Table.tsx +++ b/src/components/generic/Table/Table.tsx @@ -24,7 +24,7 @@ export const Table = ({
{rows.map((r, i) => ( - + ))}
diff --git a/src/components/generic/Table/Table.types.ts b/src/components/generic/Table/Table.types.ts index 14a72a80..617c5423 100644 --- a/src/components/generic/Table/Table.types.ts +++ b/src/components/generic/Table/Table.types.ts @@ -7,7 +7,7 @@ export type ColumnDef = { className?: string; key: string; label?: string; - renderCell?: (row: T) => ReactNode; + renderCell?: (row: T, index: number) => ReactNode; renderHeader?: () => ReactNode; width?: number; }; diff --git a/src/components/generic/Table/TableCell.tsx b/src/components/generic/Table/TableCell.tsx index d7c34854..43d65098 100644 --- a/src/components/generic/Table/TableCell.tsx +++ b/src/components/generic/Table/TableCell.tsx @@ -8,11 +8,13 @@ import styles from './Table.module.scss'; export interface TableCellProps { column: ColumnDef; row?: T; + index: number; } export const TableCell = ({ column, row, + index, }: TableCellProps): JSX.Element => { const className = clsx(styles.Table_Cell, column.className); const renderInner = (): ReactElement | null => { @@ -25,7 +27,7 @@ export const TableCell = ({ } if (row) { if (column.renderCell) { - const el = column.renderCell(row); + const el = column.renderCell(row, index); return isValidElement(el) ? el : {el}; } return {`${row?.[column.key]}`}; diff --git a/src/components/generic/Table/TableRow.tsx b/src/components/generic/Table/TableRow.tsx index 363e0fac..93f29c62 100644 --- a/src/components/generic/Table/TableRow.tsx +++ b/src/components/generic/Table/TableRow.tsx @@ -13,19 +13,21 @@ export interface TableRowProps { className?: string; columns: ColumnDef[]; row?: Row; + index?: number; } export const TableRow = ({ className, columns, row, + index = -1, }: TableRowProps): JSX.Element => { const gridTemplateColumns = columns.map((c) => c.width ? `${c.width}px` : '1fr').join(' '); if (!row) { return (
{columns.map((c) => ( - + ))}
); @@ -34,7 +36,7 @@ export const TableRow = ({ return (
{columns.map((c) => ( - + ))}
); diff --git a/src/components/generic/Warning/Warning.module.scss b/src/components/generic/Warning/Warning.module.scss new file mode 100644 index 00000000..744e70c1 --- /dev/null +++ b/src/components/generic/Warning/Warning.module.scss @@ -0,0 +1,46 @@ +@use "/src/style/flex"; +@use "/src/style/corners"; +@use "/src/style/borders"; +@use "/src/style/text"; +@use "/src/style/variables"; + +.Warning { + @include text.ui; + @include corners.normal; + @include borders.warning; + + display: grid; + grid-template-areas: + "icon header" + ". body"; + grid-template-columns: 1rem 1fr; + grid-template-rows: auto auto; + row-gap: 0.25rem; + column-gap: 0.5rem; + + padding: 1rem; + + color: var(--text-color-warning); + + background-color: var(--card-bg-warning); + + &_Icon { + grid-area: icon; + width: 1rem; + height: 1rem; + } + + &_Header { + grid-area: header; + } + + &_Body { + @include flex.column($gap: 0.5rem); + + grid-area: body; + + p { + color: inherit; + } + } +} diff --git a/src/components/generic/Warning/Warning.tsx b/src/components/generic/Warning/Warning.tsx new file mode 100644 index 00000000..cabb48ef --- /dev/null +++ b/src/components/generic/Warning/Warning.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import clsx from 'clsx'; +import { TriangleAlert } from 'lucide-react'; + +import styles from './Warning.module.scss'; + +export interface WarningProps { + className?: string; + children: ReactNode; +} + +export const Warning = ({ + className, + children, +}: WarningProps): JSX.Element => ( +
+ +

+ Warning +

+
+ {children} +
+
+); diff --git a/src/components/generic/Warning/index.ts b/src/components/generic/Warning/index.ts new file mode 100644 index 00000000..96b9f246 --- /dev/null +++ b/src/components/generic/Warning/index.ts @@ -0,0 +1,2 @@ +export type { WarningProps } from './Warning'; +export { Warning } from './Warning'; diff --git a/src/modals.ts b/src/modals.ts index 866d0d8e..accbb66d 100644 --- a/src/modals.ts +++ b/src/modals.ts @@ -1,5 +1,6 @@ import { useStore } from '@tanstack/react-store'; import { Store } from '@tanstack/store'; +import { v4 } from 'uuid'; interface Modal { id: string; @@ -24,7 +25,8 @@ export const closeModal = (id: string): void => { openModals.setState((state) => state.filter((modal) => modal.id !== id)); }; -export const useModal = (id: string) => { +export const useModal = (key?: string) => { + const id = key ?? v4(); const modal: Modal | undefined = useStore(openModals, (state) => state.find((openModal) => openModal.id === id)); return { id, diff --git a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts b/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts deleted file mode 100644 index 1e756d24..00000000 --- a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RefObject, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -export const useWizardSteps = (refs: RefObject<{ validate: () => void }>[]) => { - const navigate = useNavigate(); - const { pathname } = useLocation(); - const [step, setStep] = useState(0); - - const handleCancel = (): void => { - if (window.history.length > 1) { - navigate(-1); - } else { - navigate(`${pathname.split('/').slice(0, -1).join('/')}`); - } - }; - - const handleBack = (): void => { - if (step > 0) { - setStep(step - 1); - } else { - handleCancel(); - } - }; - - const handleProceed = (): void => { - refs[step].current?.validate(); - }; - - return { - step, - cancel: handleCancel, - back: handleBack, - advance: () => setStep(step + 1), - validatedAdvance: handleProceed, - }; -}; diff --git a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx b/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx deleted file mode 100644 index e7419887..00000000 --- a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useRef } from 'react'; -import { - generatePath, - useNavigate, - useParams, -} from 'react-router-dom'; - -import { TournamentId, UnassignedTournamentPairing } from '~/api'; -import { Button } from '~/components/generic/Button'; -import { PageWrapper } from '~/components/PageWrapper'; -import { toast } from '~/components/ToastProvider'; -import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; -import { TournamentProvider } from '~/components/TournamentProvider'; -import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; -import { useGetTournament, useOpenTournamentRound } from '~/services/tournaments'; -import { PATHS } from '~/settings'; -import { PairingsStep } from './components/PairingsStep'; -import { PairingsStepHandle } from './components/PairingsStep/PairingsStep'; -import { RosterStep } from './components/RosterStep'; -import { RosterStepHandle } from './components/RosterStep/RosterStep'; -import { useWizardSteps } from './TournamentAdvanceRoundPage.hooks'; - -export const TournamentAdvanceRoundPage = (): JSX.Element => { - const params = useParams(); - const navigate = useNavigate(); - const tournamentId = params.id! as TournamentId; // Must exist or else how did we get to this route? - const { data: tournament } = useGetTournament({ id: tournamentId }); - const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ tournamentId }); - const { mutation: openTournamentRound } = useOpenTournamentRound({ - onSuccess: (): void => { - toast.success(`Round ${nextRound + 1} pairings created!`); - navigate(`${generatePath(PATHS.tournamentDetails, { id: tournamentId })}?tab=activeRound`); - }, - }); - - // Wizard - const rosterStepRef = useRef(null); - const pairingsStepRef = useRef(null); - const { - step, - cancel, - back, - advance, - validatedAdvance, - } = useWizardSteps([ - rosterStepRef, - pairingsStepRef, - ]); - - const onConfirmPairings = async (unassignedPairings: UnassignedTournamentPairing[]): Promise => { - await openTournamentRound({ - id: tournamentId, - unassignedPairings, - }); - }; - - if (!tournament || !tournamentCompetitors) { - return
Loading...
; - } - - const nextRound = (tournament.lastRound ?? -1) + 1; - - return ( - - - {(step > 0) && ( - - )} - - - } - > - - - {step === 0 && ( - - )} - {step === 1 && ( - - )} - - - - ); -}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss deleted file mode 100644 index a4173e73..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss +++ /dev/null @@ -1,12 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.ConfirmPairingsDialog { - &_Content { - @include flex.column; - } -} diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx deleted file mode 100644 index e2b93281..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { UnassignedTournamentPairing } from '~/api'; -import { ConfirmationDialog } from '~/components/ConfirmationDialog'; -import { TournamentPairingRow } from '~/components/TournamentPairingRow'; -import { DraftTournamentPairing } from '~/components/TournamentPairingsGrid'; -import { convertDraftPairingsToUnassignedPairings } from '../PairingsStep/PairingsStep.utils'; - -import styles from './ConfirmPairingsDialog.module.scss'; - -export const confirmPairingsDialogId = 'confirm-pairings'; - -export interface ConfirmPairingsDialogProps { - nextRound: number; - manualPairings?: DraftTournamentPairing[]; - onConfirm: (pairings: UnassignedTournamentPairing[]) => void; -} - -export const ConfirmPairingsDialog = ({ - nextRound, - manualPairings = [], - onConfirm, -}: ConfirmPairingsDialogProps): JSX.Element => { - const unassignedPairings = convertDraftPairingsToUnassignedPairings(manualPairings); - - const handleConfirm = () => { - if (!unassignedPairings.length) { - throw new Error('cannot confirm non-existent pairings!'); - } - onConfirm(unassignedPairings); - }; - - return ( - -
-

The following pairings will be created:

- {unassignedPairings.map((pairing, i) => ( - - ))} -
-
- ); -}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss deleted file mode 100644 index 2256ae52..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairingStep { - @include flex.column; - @include borders.normal; - @include shadows.surface; - @include corners.normal; - @include flex.stretchy; - - padding: var(--container-padding-y) var(--container-padding-x); - background: var(--card-bg); - - &_PairingMethodSection { - @include flex.row; - } - - &_ConfirmationContent { - @include flex.column; - } - - &_ConfirmationSection { - @include flex.column($gap: 0.5rem); - } -} diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx deleted file mode 100644 index 3e02f25b..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react'; - -import { - TournamentPairingMethod, - tournamentPairingMethodOptions, - UnassignedTournamentPairing, -} from '~/api'; -import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; -import { Button } from '~/components/generic/Button'; -import { InputSelect } from '~/components/generic/InputSelect'; -import { Label } from '~/components/generic/Label'; -import { Separator } from '~/components/generic/Separator'; -import { - DraftTournamentPairing, - TournamentPairingsGrid, - TournamentPairingsGridHandle, -} from '~/components/TournamentPairingsGrid'; -import { useTournament } from '~/components/TournamentProvider'; -import { useGetDraftTournamentPairings } from '~/services/tournamentPairings'; -import { ConfirmPairingsDialog, confirmPairingsDialogId } from '../ConfirmPairingsDialog'; - -import styles from './PairingsStep.module.scss'; - -const changePairingMethodConfirmDialogId = 'confirm-change-pairing-method'; -const resetPairingsConfirmDialogId = 'confirm-reset-pairings'; - -export interface PairingsStepProps { - nextRound: number; - onConfirm: (pairings: UnassignedTournamentPairing[]) => void; -} - -export interface PairingsStepHandle { - validate: () => void; -} - -export const PairingsStep = forwardRef(({ - nextRound, - onConfirm, -}: PairingsStepProps, ref): JSX.Element => { - const tournament = useTournament(); - - // Pairing state - const isFirstRound = nextRound === 0; - const defaultPairingMethod = isFirstRound ? 'random' : tournament.pairingMethod; - const [pairingMethod, setPairingMethod] = useState(defaultPairingMethod); - const { data: draftPairingResults } = useGetDraftTournamentPairings({ - tournamentId: tournament._id, - round: nextRound, - method: pairingMethod, - }); - const [manualPairings, setManualPairings] = useState(); - useEffect(() => { - if (draftPairingResults) { - setManualPairings(draftPairingResults); - } - }, [draftPairingResults]); - - const pairingsGridRef = useRef(null); - const isDirty = pairingsGridRef.current?.isDirty ?? false; - - const { open: openChangePairingMethodConfirmDialog } = useConfirmationDialog(changePairingMethodConfirmDialogId); - const { open: openResetPairingsConfirmDialog } = useConfirmationDialog(resetPairingsConfirmDialogId); - const { open: openConfirmPairingsDialog } = useConfirmationDialog(confirmPairingsDialogId); - - const handleChangePairingMethod = (value: TournamentPairingMethod): void => { - if (isDirty) { - openChangePairingMethodConfirmDialog({ - onConfirm: () => setPairingMethod(value), - }); - } else { - setPairingMethod(value); - } - }; - - const handleReset = (): void => { - if (draftPairingResults) { - if (isDirty) { - openResetPairingsConfirmDialog({ - onConfirm: () => pairingsGridRef.current?.reset(draftPairingResults), - }); - } else { - setManualPairings(draftPairingResults); - } - } - }; - - useImperativeHandle(ref, () => ({ - validate: openConfirmPairingsDialog, - })); - - return ( -
-
- - - -
- - - - - -
- ); -}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts deleted file mode 100644 index e9b957ac..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { UnassignedTournamentPairing } from '~/api'; -import { DraftTournamentPairing } from '~/components/TournamentPairingsGrid'; - -export const convertDraftPairingsToUnassignedPairings = ( - draftTournamentPairings: DraftTournamentPairing[], -): UnassignedTournamentPairing[] => draftTournamentPairings.filter(([a, b]) => a || b).map(([a, b]) => { - const playedTables = Array.from( - new Set([ - ...(a?.playedTables ?? []), - ...(b?.playedTables ?? []), - ]), - ); - if (a && !b) { - return { - tournamentCompetitor0Id: a.id, - tournamentCompetitor1Id: null, - playedTables, - }; - } - if (!a && b) { - return { - tournamentCompetitor0Id: b.id, - tournamentCompetitor1Id: null, - playedTables, - }; - } - // We've filtered out pairings with no competitors, and handled the one-sided ones above: - return { - tournamentCompetitor0Id: a!.id, - tournamentCompetitor1Id: b!.id, - playedTables, - }; -}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts deleted file mode 100644 index 350d9f76..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PairingsStep } from './PairingsStep'; diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss deleted file mode 100644 index 68fb5952..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.RosterStep { - @include flex.column; - @include borders.normal; - @include shadows.surface; - @include corners.normal; - - padding: var(--container-padding-y) var(--container-padding-x); - background: var(--card-bg); - - &_Header { - @include flex.row; - } - - &_Actions { - @include flex.row; - - margin-left: auto; - } - - &_CompetitorContent { - @include flex.column; - - padding: 1rem calc(1rem - var(--border-width)); - } -} diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx deleted file mode 100644 index 54a46249..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - forwardRef, - ReactNode, - useImperativeHandle, - useMemo, -} from 'react'; -import { Plus } from 'lucide-react'; - -import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; -import { Button } from '~/components/generic/Button'; -import { Separator } from '~/components/generic/Separator'; -import { TournamentCompetitorCreateDialog, useTournamentCompetitorCreateDialog } from '~/components/TournamentCompetitorCreateDialog'; -import { useTournamentCompetitors } from '~/components/TournamentCompetitorsProvider'; -import { useTournament } from '~/components/TournamentProvider'; -import { TournamentRoster } from '~/components/TournamentRoster'; -import { getWarnings, sortCompetitorsByActive } from './RosterStep.utils'; - -import styles from './RosterStep.module.scss'; - -const tournamentRosterConfirmDialogId = 'tournament-roster-confirm'; - -export interface RosterStepProps { - nextRound: number; - onConfirm: () => void; -} - -export interface RosterStepHandle { - validate: () => void; -} - -export const RosterStep = forwardRef(({ - onConfirm, - nextRound, -}: RosterStepProps, ref) => { - const tournament = useTournament(); - const tournamentCompetitors = useTournamentCompetitors(); - const { open: openTournamentCompetitorCreateDialog } = useTournamentCompetitorCreateDialog(); - const { - open: openTournamentRosterConfirmDialog, - close: closeTournamentRosterConfirmDialog, - } = useConfirmationDialog(tournamentRosterConfirmDialogId); - - const sortedCompetitors = sortCompetitorsByActive(tournamentCompetitors); - - useImperativeHandle(ref, () => ({ - validate: () => { - if (!sortedCompetitors.active.length) { - // TODO: Use a toast instead and return - throw new Error('No competitors'); - } - if (sortedCompetitors.active.length > tournament.maxCompetitors) { - // TODO: Use a toast instead and return - throw new Error('Too many competitors!'); - } - for (const competitor of sortedCompetitors.active) { - const activePlayers = competitor.players.filter(({ active }) => active); - if (activePlayers.length > tournament.competitorSize) { - // TODO: Use a toast instead and return - throw new Error('One or more competitors have too many players!'); - } - if (activePlayers.length < tournament.competitorSize) { - // TODO: Use a toast instead and return - throw new Error('One or more competitors have too few players!'); - } - } - if (warnings.length > 0) { - openTournamentRosterConfirmDialog(); - } else { - onConfirm(); - } - }, - })); - - const handleConfirm = () => { - closeTournamentRosterConfirmDialog(); - onConfirm(); - }; - - const warnings: ReactNode[] = useMemo(() => getWarnings(tournament, tournamentCompetitors), [ - tournament, - tournamentCompetitors, - ]); - - return ( - <> -
-
-

- {`Adjust ${tournament.useTeams ? 'Teams' : 'Players'}`} -

-
- -
-
- - -
- - - - ); -}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts deleted file mode 100644 index 2b0d1f8f..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RosterStep } from './RosterStep'; diff --git a/src/pages/TournamentAdvanceRoundPage/index.ts b/src/pages/TournamentAdvanceRoundPage/index.ts deleted file mode 100644 index 56bd669b..00000000 --- a/src/pages/TournamentAdvanceRoundPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TournamentAdvanceRoundPage } from './TournamentAdvanceRoundPage'; diff --git a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx index e3e91291..9808731f 100644 --- a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx @@ -1,9 +1,15 @@ -import { ReactElement, useState } from 'react'; +import { + ReactElement, + useRef, + useState, +} from 'react'; import clsx from 'clsx'; import { Zap } from 'lucide-react'; +import { Button } from '~/components/generic/Button'; import { InputSelect } from '~/components/generic/InputSelect'; import { Table } from '~/components/generic/Table'; +import { ConfirmConfigureRoundDialog, ConfirmConfigureRoundDialogHandle } from '~/components/TournamentContextMenu'; import { useTournament } from '~/components/TournamentProvider'; import { useGetTournamentPairings } from '~/services/tournamentPairings'; import { TournamentDetailCard } from '../TournamentDetailCard'; @@ -19,33 +25,25 @@ export interface TournamentPairingsCardProps { export const TournamentPairingsCard = ({ className, }: TournamentPairingsCardProps): JSX.Element => { - const { _id: tournamentId, currentRound, lastRound } = useTournament(); - const [round, setRound] = useState(currentRound ?? 0); + const { _id: tournamentId, lastRound } = useTournament(); + + const roundIndexes = lastRound !== undefined ? Array.from({ length: lastRound + 2 }, (_, i) => i) : [0]; + const [round, setRound] = useState(roundIndexes.length - 1); const { data: tournamentPairings, loading } = useGetTournamentPairings({ tournamentId, round, }); + + const confirmConfigureRoundDialogRef = useRef(null); const columns = getTournamentPairingTableConfig(); const rows = (tournamentPairings || []); const showEmptyState = !loading && !rows.length; const showLoadingState = loading; - const getRoundIndexes = (): number[] => { - if (currentRound === undefined && lastRound === undefined) { - return []; - } - if (currentRound === undefined && lastRound !== undefined) { - return Array.from({ length: lastRound + 1 }, (_, i) => i); - } - if (currentRound !== undefined) { - return Array.from({ length: currentRound + 1 }, (_, i) => i); - } - return []; - }; - const roundOptions = getRoundIndexes().map((round) => ({ - label: `Round ${round + 1}`, - value: round, + const roundOptions = roundIndexes.map((i) => ({ + label: `Round ${i + 1}`, + value: i, })); const getPrimaryButtons = (): ReactElement[] | undefined => [ @@ -53,27 +51,39 @@ export const TournamentPairingsCard = ({ options={roundOptions} value={round} onChange={(selected) => setRound(selected as number)} - disabled={showLoadingState || showEmptyState} + disabled={showLoadingState || roundIndexes.length < 2} />, ]; + const handleConfigure = (): void => { + confirmConfigureRoundDialogRef.current?.open(); + }; + return ( - - {showLoadingState ? ( -
- Loading... -
- ) : ( - showEmptyState ? ( - } /> + <> + + {showLoadingState ? ( +
+ Loading... +
) : ( - - ) - )} - + showEmptyState ? ( + }> + + + ) : ( +
+ ) + )} + + + + ); }; diff --git a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx index b9893753..6189d93b 100644 --- a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx @@ -11,6 +11,7 @@ import { import { useAuth } from '~/components/AuthProvider'; import { Button } from '~/components/generic/Button'; import { toast } from '~/components/ToastProvider'; +import { TournamentCompetitorCreateDialog, useTournamentCompetitorCreateDialog } from '~/components/TournamentCompetitorCreateDialog'; import { useTournamentCompetitors } from '~/components/TournamentCompetitorsProvider'; import { TournamentCreateTeamDialog } from '~/components/TournamentCreateTeamDialog'; import { useTournamentCreateTeamDialog } from '~/components/TournamentCreateTeamDialog/TournamentCreateTeamDialog.hooks'; @@ -42,6 +43,7 @@ export const TournamentRosterCard = ({ const { mutation: publishTournament } = usePublishTournament({ successMessage: `${tournament.title} is now live!`, }); + const { open: openTournamentCompetitorCreateDialog } = useTournamentCompetitorCreateDialog(); const showLoadingState = loading; const showEmptyState = !loading && !tournamentCompetitors?.length; @@ -74,8 +76,10 @@ export const TournamentRosterCard = ({ const getPrimaryButtons = (): ReactElement[] | undefined => { const isPlayer = user && tournament.playerUserIds.includes(user._id); + const isOrganizer = user && tournament.organizerUserIds.includes(user._id); const hasMaxTeams = (competitors || []).length >= tournament.maxCompetitors; - if (user && !isPlayer && !hasMaxTeams) { + + if (tournament.status === 'published' && user && !isPlayer && !hasMaxTeams) { if (tournament.useTeams) { return [ , ]; } + if (tournament.status === 'active' && tournament.currentRound === undefined && isOrganizer) { + return [ + , + ]; + } }; const emptyStateProps = tournament.status === 'draft' && isOrganizer ? { @@ -124,6 +135,7 @@ export const TournamentRosterCard = ({ ) )} + ); }; diff --git a/src/pages/TournamentDetailPage/index.ts b/src/pages/TournamentDetailPage/index.ts index e69de29b..5e9436f8 100644 --- a/src/pages/TournamentDetailPage/index.ts +++ b/src/pages/TournamentDetailPage/index.ts @@ -0,0 +1,3 @@ +export { + TournamentDetailPage, +} from './TournamentDetailPage'; diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss b/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss new file mode 100644 index 00000000..6f8ab9a2 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss @@ -0,0 +1,78 @@ +@use "/src/style/flex"; +@use "/src/style/variables"; +@use "/src/style/borders"; +@use "/src/style/corners"; +@use "/src/style/shadows"; +@use "/src/style/text"; +@use "/src/style/variants"; + +.TournamentPairingsPage { + @include flex.column; + @include variants.card; + + &_Header { + @include flex.row; + + padding: var(--container-padding-y) var(--container-padding-x) 0; + } + + &_TableRow { + padding: 0 var(--container-padding-x); + } + + &_Form { + display: grid; + grid-template-areas: + ". tableHeader competitorsHeader" + "alerts tableInputs competitorsGrid"; + grid-template-columns: auto 6rem 1fr; + grid-template-rows: auto auto; + gap: 1rem; + + padding: 0 var(--container-padding-x) var(--container-padding-y); + + &_Alerts { + display: grid; + grid-area: alerts; + gap: 0.5rem; + place-items: center; + justify-content: center; + + width: 1.5rem; + } + + &_TableHeader { + grid-area: tableHeader; + } + + &_CompetitorsHeader { + grid-area: competitorsHeader; + } + + &_CompetitorsGrid { + grid-area: competitorsGrid; + } + + &_CompetitorCard { + @include flex.row; + + padding: 0.5rem; + } + + &_TableInputs { + display: grid; + grid-area: tableInputs; + gap: 0.5rem; + } + + &_TableInput { + display: flex; + align-items: center; + justify-content: center; + + button { + width: 100%; + } + } + } +} diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts b/src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts new file mode 100644 index 00000000..a4f9e738 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { TournamentCompetitorId } from '~/api'; + +const tournamentCompetitorIdSchema = z.union([ + z.string().transform((val) => val as TournamentCompetitorId), + z.null(), +]); + +export const draftTournamentPairingSchema = z.object({ + table: z.union([z.number(), z.null()]), + tournamentCompetitor0Id: tournamentCompetitorIdSchema, + tournamentCompetitor1Id: tournamentCompetitorIdSchema, +}); + +export type TournamentPairingFormItem = z.infer; + +export const schema = z.object({ + pairings: z.array(draftTournamentPairingSchema), +}); + +export type FormData = z.infer; + +export const sanitize = ( + pairings: unknown[] = [], +): TournamentPairingFormItem[] => pairings.map((p) => draftTournamentPairingSchema.parse(p)); diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx b/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx new file mode 100644 index 00000000..c0769e50 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx @@ -0,0 +1,269 @@ +import { + MouseEvent, + useCallback, + useEffect, + useState, +} from 'react'; +import { + useFieldArray, + useForm, + useWatch, +} from 'react-hook-form'; +import { + generatePath, + useNavigate, + useParams, +} from 'react-router-dom'; +import { UniqueIdentifier } from '@dnd-kit/core'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { + DraftTournamentPairing, + TournamentId, + TournamentPairingMethod, + tournamentPairingMethodOptions, +} from '~/api'; +import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; +import { Button } from '~/components/generic/Button'; +import { InfoPopover } from '~/components/generic/InfoPopover'; +import { InputSelect } from '~/components/generic/InputSelect'; +import { Label } from '~/components/generic/Label'; +import { Pulsar } from '~/components/generic/Pulsar'; +import { Separator } from '~/components/generic/Separator'; +import { SortableGrid } from '~/components/generic/SortableGrid'; +import { PageWrapper } from '~/components/PageWrapper'; +import { toast } from '~/components/ToastProvider'; +import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; +import { TournamentProvider } from '~/components/TournamentProvider'; +import { ConfirmPairingsDialog } from '~/pages/TournamentPairingsPage/components/ConfirmPairingsDialog'; +import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; +import { useCreateTournamentPairings, useGetDraftTournamentPairings } from '~/services/tournamentPairings'; +import { useGetTournament } from '~/services/tournaments'; +import { PATHS } from '~/settings'; +import { + FormData, + sanitize, + schema, +} from './TournamentPairingsPage.schema'; +import { + flattenPairings, + getPairingsStatuses, + renderCompetitorCard, + updatePairings, +} from './TournamentPairingsPage.utils'; + +import styles from './TournamentPairingsPage.module.scss'; + +const WIDTH = 800; + +export const TournamentPairingsPage = (): JSX.Element => { + const params = useParams(); + const navigate = useNavigate(); + + const tournamentId = params.id! as TournamentId; // Must exist or else how did we get to this route? + const { data: tournament } = useGetTournament({ id: tournamentId }); + const lastRound = tournament?.lastRound ?? -1; + + const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ + tournamentId, + includeRankings: lastRound, + }); + const isFirstRound = (tournament?.lastRound ?? -1) < 0; + const defaultPairingMethod = isFirstRound ? 'random' : (tournament?.pairingMethod ?? 'adjacent'); + const [pairingMethod, setPairingMethod] = useState(defaultPairingMethod); + + const round = lastRound + 1; + const { data: generatedPairings } = useGetDraftTournamentPairings(tournament ? { + tournamentId, + round, + method: pairingMethod, + } : 'skip'); + + const { mutation: createTournamentPairings } = useCreateTournamentPairings({ + onSuccess: (): void => { + toast.success(`Round ${round + 1} pairings created!`); + navigate(`${generatePath(PATHS.tournamentDetails, { id: tournamentId })}?tab=pairings`); + }, + }); + + const { + id: confirmChangePairingMethodDialogId, + open: openConfirmChangePairingMethodDialog, + } = useConfirmationDialog(); + const { + id: confirmResetPairingsDialogId, + open: openConfirmResetPairingsDialog, + } = useConfirmationDialog(); + const { + id: confirmPairingsDialogId, + open: openConfirmPairingsDialog, + } = useConfirmationDialog(); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + pairings: sanitize(generatedPairings), + }, + mode: 'onSubmit', + }); + const reset = useCallback((pairings: unknown[]) => form.reset({ + pairings: sanitize(pairings), + }), [form]); + useEffect(() => { + if (tournament && generatedPairings) { + reset(generatedPairings); + } + }, [tournament, generatedPairings, reset]); + const { fields } = useFieldArray({ + control: form.control, + name: 'pairings', + }); + const pairings = useWatch({ + control: form.control, + name: 'pairings', + }); + + if (!tournament || !tournamentCompetitors) { + return
Loading...
; + } + + const tableCount = Math.ceil(tournament.maxCompetitors / 2); + const tableOptions = [ + ...Array.from({ length: tableCount }).map((_, i) => ({ + label: String(i + 1), + value: i, + })), + { label: 'Auto', value: -1 }, + ]; + + const handleChange = (items: UniqueIdentifier[]): void => { + updatePairings(items, form.reset); + }; + + const handleChangePairingMethod = (value: TournamentPairingMethod): void => { + if (form.formState.isDirty) { + openConfirmChangePairingMethodDialog({ + onConfirm: () => setPairingMethod(value), + }); + } else { + setPairingMethod(value); + } + }; + + const handleReset = (): void => { + if (generatedPairings) { + if (form.formState.isDirty) { + openConfirmResetPairingsDialog({ + onConfirm: () => reset(generatedPairings), + }); + } else { + reset(generatedPairings); + } + } + }; + + const handleCancel = (_e: MouseEvent): void => { + // TODO: If dirty, open confirmation dialog + navigate(-1); + }; + + const handleProceed = (_e: MouseEvent): void => { + openConfirmPairingsDialog(); + }; + + const handleConfirm = async (pairings: DraftTournamentPairing[]): Promise => { + await createTournamentPairings({ tournamentId, round, pairings }); + }; + + const pairingStatuses = getPairingsStatuses(tournamentCompetitors, pairings); + + return ( + + + + + } + > + + +
+
+ + + +
+ +
+
+ {fields.map((field, i) => { + const { status, message } = pairingStatuses[i]; + const statusColors: Record = { + 'error': 'red', + 'warning': 'yellow', + 'ok': 'green', + }; + return ( + + + + ); + })} +
+ +
+ {fields.map((field, i) => ( +
+ form.setValue(`pairings.${i}.table`, value as number, { shouldDirty: true })} + /> +
+ ))} +
+ + renderCompetitorCard(id, state, tournamentCompetitors)} + /> +
+
+ + + +
+
+
+ ); +}; diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx b/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx new file mode 100644 index 00000000..f3cf4905 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx @@ -0,0 +1,175 @@ +import { ReactElement } from 'react'; +import { UseFormReset } from 'react-hook-form'; +import { UniqueIdentifier } from '@dnd-kit/core'; + +import { TournamentCompetitor, TournamentCompetitorId } from '~/api'; +import { Pulsar } from '~/components/generic/Pulsar'; +import { IdentityBadge } from '~/components/IdentityBadge'; +import { getTournamentCompetitorDisplayName } from '~/utils/common/getTournamentCompetitorDisplayName'; +import { FormData, TournamentPairingFormItem } from './TournamentPairingsPage.schema'; + +import styles from './TournamentPairingsPage.module.scss'; + +export const updatePairings = (items: UniqueIdentifier[], reset: UseFormReset): void => reset(({ pairings }) => ({ + pairings: pairings.map((p, i) => { + const tournamentCompetitor0Id = items[i * 2] === 'bye' ? null : items[i * 2] as TournamentCompetitorId; + const tournamentCompetitor1Id = items[i * 2 + 1] === 'bye' ? null : items[i * 2 + 1] as TournamentCompetitorId; + return { + ...p, + tournamentCompetitor0Id, + tournamentCompetitor1Id, + }; + }), +}), { + keepDefaultValues: true, +}); + +/** + * + * @param pairings + * @returns + */ +export const flattenPairings = ( + pairings: TournamentPairingFormItem[] = [], +): UniqueIdentifier[] => { + const result: UniqueIdentifier[] = []; + for (const pairing of pairings) { + result.push(pairing.tournamentCompetitor0Id ?? 'bye'); + result.push(pairing.tournamentCompetitor1Id ?? 'bye'); + } + return result; +}; + +export type PairingStatus = { + status: 'error' | 'warning' | 'ok'; + message: string; +}; + +/** + * + * @param rankedCompetitors + * @param pairings + * @returns + */ +export const getPairingsStatuses = ( + rankedCompetitors: TournamentCompetitor[], + pairings: TournamentPairingFormItem[], +): PairingStatus[] => { + const tableCount: Record = {}; + + // First pass: Count table numbers: + for (const pairing of pairings) { + // Count up table assignments which are not null or "auto" (-1): + if (pairing.table !== null && pairing.table !== -1) { + tableCount[pairing.table] = (tableCount[pairing.table] || 0) + 1; + } + } + + // Second pass: Mark duplicates: + const hasDuplicateTableAssignments = pairings.map((pairing) => ( + pairing.table !== null && tableCount[pairing.table] > 1 + )); + + return pairings.map((pairing, i) => { + const competitorA = rankedCompetitors.find((c) => c._id === pairing.tournamentCompetitor0Id); + const competitorB = rankedCompetitors.find((c) => c._id === pairing.tournamentCompetitor1Id); + + if (hasDuplicateTableAssignments[i]) { + return { + status: 'error', + message: 'This table is assigned more than once.', + }; + } + if (competitorA && competitorB) { + if ( + (competitorA?.opponentIds ?? []).includes(competitorB._id) || + (competitorB?.opponentIds ?? []).includes(competitorA._id) + ) { + return { + status: 'error', + message: 'These opponents have already played each other.', + }; + } + } + if (competitorA && !competitorB && (competitorA?.byeRounds ?? []).length) { + const displayName = getTournamentCompetitorDisplayName(competitorA); + return { + status: 'warning', + message: `${displayName} has already had a bye.`, + }; + } + if (!competitorA && competitorB && (competitorB?.byeRounds ?? []).length) { + const displayName = getTournamentCompetitorDisplayName(competitorB); + return { + status: 'warning', + message: `${displayName} has already had a bye.`, + }; + } + if (pairing.table !== null && pairing.table > -1) { + if (competitorA && (competitorA?.playedTables ?? []).includes(pairing.table)) { + const displayName = getTournamentCompetitorDisplayName(competitorA); + return { + status: 'warning', + message: `${displayName} has already played this table.`, + }; + } + if (competitorB && (competitorB?.playedTables ?? []).includes(pairing.table)) { + const displayName = getTournamentCompetitorDisplayName(competitorB); + return { + status: 'warning', + message: `${displayName} has already played this table.`, + }; + } + } + + return { + status: 'ok', + message: 'Pairing is valid.', + }; + }); +}; + +/** + * + * @param id + * @param state + * @returns + */ +export const renderCompetitorCard = ( + id: UniqueIdentifier, + state: { + activeId: UniqueIdentifier | null, + isActive: boolean, + isOverlay: boolean, + }, + tournamentCompetitors: TournamentCompetitor[], +): ReactElement => { + + if (!tournamentCompetitors) { + return ( +
+ +
+ ); + } + + const rankedCompetitor = tournamentCompetitors.find((c) => c._id === id); + + if (!rankedCompetitor) { + return ( +
+ +
+ ); + } + + const isValid = !!state.activeId && !(rankedCompetitor?.opponentIds ?? []).includes(state.activeId as TournamentCompetitorId); + + return ( +
+ +
{rankedCompetitor.rank !== undefined ? rankedCompetitor.rank + 1 : '-'}
+ +
+ ); +}; diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss new file mode 100644 index 00000000..d1eac8eb --- /dev/null +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss @@ -0,0 +1,49 @@ +@use "/src/style/variables"; +@use "/src/style/flex"; +@use "/src/style/text"; + +.ConfirmPairingsDialog { + padding-bottom: var(--container-padding-y); + + &_Content { + margin: 0 var(--container-padding-x); + } + + &_Filters { + @include flex.row; + } + + &_EmptyState { + @include flex.stretchy; + @include flex.centered; + @include text.ui($muted: true); + + padding-bottom: 4rem; + } + + &_Row { + padding: 0 var(--container-padding-x); + } + + &_Table { + @include text.ui; + + font-size: 1.5rem; + font-weight: 300; + line-height: 1.75rem; + } + + &_Pairing { + padding: 0.5rem; + } + + &_MatchIndicatorIcon { + width: 1.25rem; + height: 1.25rem; + stroke: currentcolor; + } + + &_MatchIndicatorInner { + @include text.ui; + } +} diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx new file mode 100644 index 00000000..fdf0efb9 --- /dev/null +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx @@ -0,0 +1,82 @@ +import { DraftTournamentPairing, TournamentCompetitor } from '~/api'; +import { ConfirmationDialog } from '~/components/ConfirmationDialog'; +import { ColumnDef, Table } from '~/components/generic/Table'; +import { Warning } from '~/components/generic/Warning'; +import { TournamentPairingRow } from '~/components/TournamentPairingRow'; +import { useTournament } from '~/components/TournamentProvider'; +import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; +import { assignTables } from './ConfirmPairingsDialog.utils'; + +import styles from './ConfirmPairingsDialog.module.scss'; + +export interface ConfirmPairingsDialogProps { + competitors: TournamentCompetitor[]; + id: string; + onConfirm: (assignedPairings: DraftTournamentPairing[]) => void; + pairings: TournamentPairingFormItem[]; +} + +export const ConfirmPairingsDialog = ({ + competitors, + id, + onConfirm, + pairings, +}: ConfirmPairingsDialogProps): JSX.Element => { + const { maxCompetitors } = useTournament(); + + const assignedPairings = assignTables(pairings.filter((pairing) => ( + pairing.tournamentCompetitor0Id || pairing.tournamentCompetitor1Id + )).map((pairing) => ({ + ...pairing, + playedTables: Array.from(new Set([ + ...competitors.find((c) => c._id === pairing.tournamentCompetitor0Id)?.playedTables ?? [], + ...competitors.find((c) => c._id === pairing.tournamentCompetitor1Id)?.playedTables ?? [], + ])), + })), Math.ceil(maxCompetitors / 2)); + + const handleConfirm = (): void => { + onConfirm(assignedPairings); + }; + + const columns: ColumnDef[] = [ + { + key: 'table', + label: 'Table', + width: 40, + align: 'center', + renderCell: (r) => ( +
+ {r.table === null ? '-' : r.table + 1} +
+ ), + }, + { + key: 'pairing', + label: 'Pairing', + align: 'center', + renderCell: (r) => ( + + ), + }, + ]; + + return ( + +

+ The following pairings will be created: +

+
+ + Once created, pairings cannot be edited. Please ensure all competitors are present and ready to play! + + + ); +}; diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts new file mode 100644 index 00000000..e828f2a9 --- /dev/null +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts @@ -0,0 +1,74 @@ +import { DraftTournamentPairing } from '~/api'; +import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; + +export const assignTables = ( + pairings: (TournamentPairingFormItem & { + playedTables: (number | null)[]; + })[], + tableCount: number, +): DraftTournamentPairing[] => { + + // Get tables already in use: + const usedTables = new Set(pairings.filter((p) => p.table !== null && p.table > -1).map((p) => p.table)); + + // Create pool of available tables: + const availableTables = Array.from({ length: tableCount }, (_, i) => i).filter((table) => !usedTables.has(table)); + + const assignedPairings = []; + + for (const { playedTables, ...pairing } of pairings) { + const ids = [ + pairing.tournamentCompetitor0Id, + pairing.tournamentCompetitor1Id, + ].filter((id) => id !== null); + + // If not a bye... + if (ids.length === 2) { + const fullPairing = { + ...pairing, + tournamentCompetitor0Id: ids[0], + tournamentCompetitor1Id: ids[1], + }; + + // ...and the table is assigned, don't do anything: + if (pairing.table !== -1) { + assignedPairings.push(fullPairing); + } else { + // Otherwise, find best available table (prefer unplayed tables): + const table = availableTables.find((table) => !playedTables.includes(table)) ?? availableTables[0] ?? null; + + // Remove assigned table from available pool: + const index = availableTables.indexOf(table); + if (index > -1) { + availableTables.splice(index, 1); + } + + assignedPairings.push({ + ...fullPairing, + table, + }); + } + + } + + // If it is a bye, force table to be null: + if (ids.length === 1) { + assignedPairings.push({ + table: null, + tournamentCompetitor0Id: ids[0], + tournamentCompetitor1Id: null, + }); + } + + // Ignore pairings which are empty. + } + return assignedPairings.sort((a, b) => { + if (a.table === null) { + return 1; + } + if (b.table === null) { + return -1; + } + return a.table - b.table; + }); +}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/index.ts b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts similarity index 66% rename from src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/index.ts rename to src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts index 9396ec78..bb257aca 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/index.ts +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts @@ -1,4 +1,4 @@ export { ConfirmPairingsDialog, - confirmPairingsDialogId, + type ConfirmPairingsDialogProps, } from './ConfirmPairingsDialog'; diff --git a/src/pages/TournamentPairingsPage/index.ts b/src/pages/TournamentPairingsPage/index.ts new file mode 100644 index 00000000..1202d929 --- /dev/null +++ b/src/pages/TournamentPairingsPage/index.ts @@ -0,0 +1,3 @@ +export { + TournamentPairingsPage, +} from './TournamentPairingsPage'; diff --git a/src/routes.tsx b/src/routes.tsx index 8cf47220..6783efd6 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -25,11 +25,10 @@ import { SettingsPage, UserProfileForm, } from '~/pages/SettingsPage'; -import { TournamentAdvanceRoundPage } from '~/pages/TournamentAdvanceRoundPage'; import { TournamentCreatePage } from '~/pages/TournamentCreatePage'; -import { TournamentDetailPage } from '~/pages/TournamentDetailPage/TournamentDetailPage'; +import { TournamentDetailPage } from '~/pages/TournamentDetailPage'; import { TournamentEditPage } from '~/pages/TournamentEditPage/TournamentEditPage'; -// import { TournamentDetailPage } from '~/pages/TournamentDetailPage'; +import { TournamentPairingsPage } from '~/pages/TournamentPairingsPage'; import { TournamentsPage } from '~/pages/TournamentsPage'; import { PATHS } from '~/settings'; @@ -134,9 +133,9 @@ export const routes = [ element: , }, { - path: PATHS.tournamentAdvanceRound, + path: PATHS.tournamentPairings, visibility: [], - element: , + element: , }, { path: PATHS.tournamentEdit, diff --git a/src/services/tournamentPairings.ts b/src/services/tournamentPairings.ts index 554482a4..cc8115ef 100644 --- a/src/services/tournamentPairings.ts +++ b/src/services/tournamentPairings.ts @@ -1,5 +1,5 @@ import { api } from '~/api'; -import { createQueryHook } from '~/services/utils'; +import { createMutationHook, createQueryHook } from '~/services/utils'; // Basic Queries export const useGetTournamentPairing = createQueryHook(api.tournamentPairings.getTournamentPairing); @@ -8,3 +8,6 @@ export const useGetTournamentPairings = createQueryHook(api.tournamentPairings.g // Special Queries export const useGetActiveTournamentPairingsByUser = createQueryHook(api.tournamentPairings.getActiveTournamentPairingsByUser); export const useGetDraftTournamentPairings = createQueryHook(api.tournamentPairings.getDraftTournamentPairings); + +// Mutations +export const useCreateTournamentPairings = createMutationHook(api.tournamentPairings.createTournamentPairings); diff --git a/src/services/tournaments.ts b/src/services/tournaments.ts index e9d7d7b8..c6d34789 100644 --- a/src/services/tournaments.ts +++ b/src/services/tournaments.ts @@ -16,8 +16,8 @@ export const useUpdateTournament = createMutationHook(api.tournaments.updateTour export const useDeleteTournament = createMutationHook(api.tournaments.deleteTournament); // Special Mutations -export const useCloseTournamentRound = createMutationHook(api.tournaments.closeTournamentRound); export const useEndTournament = createMutationHook(api.tournaments.endTournament); -export const useOpenTournamentRound = createMutationHook(api.tournaments.openTournamentRound); +export const useEndTournamentRound = createMutationHook(api.tournaments.endTournamentRound); export const usePublishTournament = createMutationHook(api.tournaments.publishTournament); export const useStartTournament = createMutationHook(api.tournaments.startTournament); +export const useStartTournamentRound = createMutationHook(api.tournaments.startTournamentRound); diff --git a/src/settings.ts b/src/settings.ts index 002f6da5..f2dd5d39 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -16,9 +16,9 @@ export const PATHS = { dashboard: '/dashboard', matchResultDetails: '/match-results/:id', matchResults: '/match-results', - tournamentAdvanceRound: '/tournaments/:id/advance', tournamentCreate: '/tournaments/create', tournamentDetails: '/tournaments/:id', tournamentEdit: '/tournaments/:id/edit', + tournamentPairings: '/tournaments/:id/pairings', tournaments: '/tournaments', } as const; diff --git a/src/style/_variables.scss b/src/style/_variables.scss index 78805343..05666b2d 100644 --- a/src/style/_variables.scss +++ b/src/style/_variables.scss @@ -26,6 +26,12 @@ @use "@radix-ui/colors/tomato-dark.css"; @use "@radix-ui/colors/tomato.css"; +// Info +@use "@radix-ui/colors/blue-alpha.css"; +@use "@radix-ui/colors/blue-dark-alpha.css"; +@use "@radix-ui/colors/blue-dark.css"; +@use "@radix-ui/colors/blue.css"; + :root { --modal-inner-gutter: 1rem; --modal-outer-gutter: 0.5rem; @@ -66,6 +72,13 @@ --text-color-negative: var(--tomato-9); --text-color-success: var(--grass-9); --text-color-warning: var(--amber-11); + --text-color-blue: var(--blue-9); + + // Solid backgrounds + --bg-blue: var(--blue-9); + --bg-green: var(--grass-9); + --bg-red: var(--tomato-9); + --bg-yellow: var(--amber-9); // VARIANTS From 2b84ee949fc0574d34bc3dbcaa154790240af0c4 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 14 Jul 2025 20:49:20 +0200 Subject: [PATCH 24/31] fix: Ensure round 0 rankings can be included --- .../queries/getTournamentCompetitorsByTournament.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index 2cf22a39..1b17b134 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -17,7 +17,7 @@ export const getTournamentCompetitorsByTournament = async ( const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - const rankings = args.includeRankings && args.includeRankings > -1 ? await getTournamentRankings(ctx, { + const rankings = args.includeRankings !== undefined && args.includeRankings > -1 ? await getTournamentRankings(ctx, { tournamentId: args.tournamentId, round: args.includeRankings, }) : undefined; From 66302d77fda88e0cfc88d2c9d12a7aaa897479c0 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 15 Jul 2025 08:41:35 +0200 Subject: [PATCH 25/31] Refactor tournament actions (#114) * refactor: Improve tournament actions * chore: Clean-up Convex errors * fix: Do not try to clean up current round timer on tournament end * fix: Don't allow players to be removed from tournament * chore: Update test tournament banner image * Update TournamentCard.tsx * fix: Ensure round 0 rankings can be included * fix: Fix end tournament round context menu behavior --- .../mutations/updateTournamentCompetitor.ts | 9 + .../tournaments/_helpers/deepenTournament.ts | 14 +- convex/_model/tournaments/index.ts | 11 + .../tournaments/mutations/endTournament.ts | 10 +- .../queries/getTournamentOpenRound.ts | 1 + convex/_model/utils/createTestTournament.ts | 2 +- convex/common/errors.ts | 6 +- src/api.ts | 1 + .../ConfirmationDialog.hooks.ts | 7 +- .../ConfirmationDialog/ConfirmationDialog.tsx | 24 +- .../ConfirmationDialog.types.ts | 19 ++ src/components/ConfirmationDialog/index.ts | 5 +- .../ToastProvider/ToastProvider.store.ts | 2 +- .../TournamentActionsProvider.context.ts | 12 + .../TournamentActionsProvider.hooks.tsx | 229 ++++++++++++++++++ .../TournamentActionsProvider.tsx | 22 ++ .../TournamentActionsProvider/index.ts | 2 + .../utils/validateConfigureRound.tsx | 66 +++++ .../TournamentCard/TournamentCard.tsx | 47 ++-- .../TournamentContextMenu.tsx | 150 +----------- .../TournamentContextMenu.utils.ts | 10 - .../ConfirmConfigureRoundDialog.module.scss | 5 - .../ConfirmConfigureRoundDialog.tsx | 95 -------- .../ConfirmConfigureRoundDialog.utils.tsx | 41 ---- .../ConfirmConfigureRoundDialog/index.ts | 5 - src/components/TournamentContextMenu/index.ts | 4 - .../Dialog/DialogDescription.module.scss | 4 + .../TournamentDetailPage.tsx | 71 +++--- .../TournamentPairingsCard.tsx | 31 +-- 29 files changed, 489 insertions(+), 416 deletions(-) create mode 100644 src/components/ConfirmationDialog/ConfirmationDialog.types.ts create mode 100644 src/components/TournamentActionsProvider/TournamentActionsProvider.context.ts create mode 100644 src/components/TournamentActionsProvider/TournamentActionsProvider.hooks.tsx create mode 100644 src/components/TournamentActionsProvider/TournamentActionsProvider.tsx create mode 100644 src/components/TournamentActionsProvider/index.ts create mode 100644 src/components/TournamentActionsProvider/utils/validateConfigureRound.tsx delete mode 100644 src/components/TournamentContextMenu/TournamentContextMenu.utils.ts delete mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss delete mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx delete mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx delete mode 100644 src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts diff --git a/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts index c91a2292..a21eda45 100644 --- a/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts @@ -34,6 +34,15 @@ export const updateTournamentCompetitor = async ( if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_MODIFY_ARCHIVED_TOURNAMENT')); } + // If a tournament is active, never allow existing players to be removed, as this will break tournament results/rankings: + if (tournament.status === 'active') { + const updatedUserIds = new Set(args.players.map((p) => p.userId)); + for (const player of tournamentCompetitor.players) { + if (!updatedUserIds.has(player.userId)) { + throw new ConvexError(getErrorMessage('CANNOT_REMOVE_PLAYER_FROM_ACTIVE_TOURNAMENT')); + } + } + } // ---- EXTENDED AUTH CHECK ---- /* These user IDs can make changes to this tournament competitor: diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index e58a7098..7096b60f 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -36,22 +36,20 @@ export const deepenTournament = async ( ], [] as Id<'users'>[]); // Computed properties (easy to do, but used so frequently, it's nice to include them by default) - const playerCount = playerUserIds.length; - const activePlayerCount = activePlayerUserIds.length; - const maxPlayers = tournament.maxCompetitors * tournament.competitorSize; - const useTeams = tournament.competitorSize > 1; + const nextRound = (tournament.currentRound ?? tournament.lastRound ?? -1) + 1; return { ...tournament, logoUrl, bannerUrl, competitorCount, - activePlayerCount, - playerCount, + activePlayerCount: activePlayerUserIds.length, + playerCount: playerUserIds.length, playerUserIds, activePlayerUserIds, - maxPlayers, - useTeams, + maxPlayers : tournament.maxCompetitors * tournament.competitorSize, + useTeams: tournament.competitorSize > 1, + nextRound: nextRound < tournament.roundCount ? nextRound : undefined, }; }; diff --git a/convex/_model/tournaments/index.ts b/convex/_model/tournaments/index.ts index 68172dbe..38281fe7 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -11,6 +11,17 @@ export const tournamentsTable = defineTable({ .index('by_status', ['status']); export type TournamentId = Id<'tournaments'>; +export enum TournamentActionKey { + Edit = 'edit', + Delete = 'delete', + Publish = 'publish', + Cancel = 'cancel', + Start = 'start', + ConfigureRound = 'configureRound', + StartRound = 'startRound', + EndRound = 'endRound', + End = 'end', +} // Helpers export { checkTournamentAuth } from './_helpers/checkTournamentAuth'; diff --git a/convex/_model/tournaments/mutations/endTournament.ts b/convex/_model/tournaments/mutations/endTournament.ts index 54feafad..b529ec6f 100644 --- a/convex/_model/tournaments/mutations/endTournament.ts +++ b/convex/_model/tournaments/mutations/endTournament.ts @@ -6,7 +6,6 @@ import { import { MutationCtx } from '../../../_generated/server'; import { getErrorMessage } from '../../../common/errors'; -import { deleteTournamentTimerByTournament } from '../../tournamentTimers'; import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; export const endTournamentArgs = v.object({ @@ -45,14 +44,11 @@ export const endTournament = async ( if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('TOURNAMENT_ALREADY_ARCHIVED')); } + if (tournament.currentRound !== undefined) { + throw new ConvexError(getErrorMessage('CANNOT_END_TOURNAMENT_MID_ROUND')); + } // ---- PRIMARY ACTIONS ---- - // Clean up TournamentTimer: - await deleteTournamentTimerByTournament(ctx, { - tournamentId: tournament._id, - round: tournament.currentRound, - }); - // End the tournament: await ctx.db.patch(args.id, { status: 'archived', diff --git a/convex/_model/tournaments/queries/getTournamentOpenRound.ts b/convex/_model/tournaments/queries/getTournamentOpenRound.ts index 2643fa5d..3f2bfb38 100644 --- a/convex/_model/tournaments/queries/getTournamentOpenRound.ts +++ b/convex/_model/tournaments/queries/getTournamentOpenRound.ts @@ -48,6 +48,7 @@ export const getTournamentOpenRound = async ( matchResultsProgress: { required: relevantPairingIds.length * tournament.competitorSize, submitted: relevantMatchResultIds.length, + remaining: (relevantPairingIds.length * tournament.competitorSize) - relevantMatchResultIds.length, }, // TODO: Get timer }; diff --git a/convex/_model/utils/createTestTournament.ts b/convex/_model/utils/createTestTournament.ts index 57c011cd..1c9c805f 100644 --- a/convex/_model/utils/createTestTournament.ts +++ b/convex/_model/utils/createTestTournament.ts @@ -60,7 +60,7 @@ export const createTestTournament = async ( playingTime: 3, }, logoStorageId: 'kg208wxmb55v36bh9qnkqc0c397j1rmb' as Id<'_storage'>, - bannerStorageId: 'kg21scbttz4t1hxxcs9qjcsvkh7j0nkh' as Id<'_storage'>, + bannerStorageId: 'kg250q9ezj209wpxgn0xqfca297kckc8' as Id<'_storage'>, }); // 3. Create competitors diff --git a/convex/common/errors.ts b/convex/common/errors.ts index ec67e853..de72e21f 100644 --- a/convex/common/errors.ts +++ b/convex/common/errors.ts @@ -10,7 +10,8 @@ export const errors = { CANNOT_MODIFY_ARCHIVED_TOURNAMENT: 'Cannot modify an archived tournament.', CANNOT_MODIFY_PUBLISHED_TOURNAMENT_COMPETITORS: 'Cannot modify competitor format for a published tournament.', CANNOT_REMOVE_ANOTHER_PLAYER: 'Cannot remove another player.', - CANNOT_REMOVE_COMPETITOR_FROM_ACTIVE_TOURNAMENT: 'Cannot add a competitor to an on-going tournament.', + CANNOT_REMOVE_COMPETITOR_FROM_ACTIVE_TOURNAMENT: 'Cannot remove a competitor from an on-going tournament.', + CANNOT_REMOVE_PLAYER_FROM_ACTIVE_TOURNAMENT: 'Cannot remove a player from an on-going tournament.', CANNOT_MODIFY_ANOTHER_TOURNAMENT_COMPETITOR: 'Cannot modify another tournament competitor.', // Tournament Lifecycle @@ -29,6 +30,7 @@ export const errors = { CANNOT_PUBLISH_ACTIVE_TOURNAMENT: 'Cannot publish a tournament which is already active.', CANNOT_END_PUBLISHED_TOURNAMENT: 'Cannot end a tournament which has not started.', CANNOT_END_DRAFT_TOURNAMENT: 'Cannot end a tournament which is still a draft.', + CANNOT_END_TOURNAMENT_MID_ROUND: 'Cannot end a tournament which mid-round.', TOURNAMENT_ALREADY_HAS_OPEN_ROUND: 'Tournament already has an open round.', TOURNAMENT_DOES_NOT_HAVE_OPEN_ROUND: 'Tournament does not have a currently open round.', @@ -43,8 +45,6 @@ export const errors = { TOURNAMENT_TIMER_ALREADY_PAUSED: 'Tournament timer is already paused.', TOURNAMENT_TIMER_ALREADY_RUNNING: 'Tournament timer is already running.', TOURNAMENT_TIMER_ALREADY_EXISTS: 'Tournament already has a timer for this round.', - CANNOT_SUBSTITUTE_ONLY_ONE_PLAYER: 'Cannot substitute only one player.', - INVALID_PAIRING_LENGTH: 'foo', // Missing docs FILE_NOT_FOUND: 'Could not find a file with that ID.', diff --git a/src/api.ts b/src/api.ts index f38f4fd8..2193ecae 100644 --- a/src/api.ts +++ b/src/api.ts @@ -52,6 +52,7 @@ export { // Tournaments export { type TournamentDeep as Tournament, + TournamentActionKey, type TournamentId, } from '../convex/_model/tournaments'; diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts index 29d02da2..9ecefcd8 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts +++ b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts @@ -1,9 +1,4 @@ import { useModal } from '~/modals'; - -type ConfirmationDialogData = { - title?: string; - description?: string; - onConfirm: () => void; -}; +import { ConfirmationDialogData } from './ConfirmationDialog.types'; export const useConfirmationDialog = (id?: string) => useModal(id); diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 635231a6..e75ae3dc 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,6 +1,6 @@ -import { ReactNode } from 'react'; import clsx from 'clsx'; +import { ConfirmationDialogProps } from '~/components/ConfirmationDialog/ConfirmationDialog.types'; import { Button } from '~/components/generic/Button'; import { ControlledDialog, @@ -9,23 +9,10 @@ import { DialogHeader, } from '~/components/generic/Dialog'; import { ScrollArea } from '~/components/generic/ScrollArea'; -import { ElementIntent } from '~/types/componentLib'; import { useConfirmationDialog } from './ConfirmationDialog.hooks'; import styles from './ConfirmationDialog.module.scss'; -export interface ConfirmationDialogProps { - children?: ReactNode; - className?: string; - description?: string; - id: string; - intent?: ElementIntent; - onConfirm?: () => void; - title: string; - disabled?: boolean; - disablePadding?: boolean; -} - export const ConfirmationDialog = ({ children, className, @@ -36,6 +23,8 @@ export const ConfirmationDialog = ({ title, disabled = false, disablePadding = false, + cancelLabel = 'Cancel', + confirmLabel = 'Confirm', }: ConfirmationDialogProps): JSX.Element => { const { close, data } = useConfirmationDialog(id); const handleConfirm = (): void => { @@ -49,7 +38,7 @@ export const ConfirmationDialog = ({ }; return ( - + {(data?.description || description) && ( @@ -57,15 +46,16 @@ export const ConfirmationDialog = ({ )}
+ {data?.children} {children}
diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.types.ts b/src/components/ConfirmationDialog/ConfirmationDialog.types.ts new file mode 100644 index 00000000..01fd77da --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialog.types.ts @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +import { ElementIntent } from '~/types/componentLib'; + +export interface ConfirmationDialogProps { + children?: ReactNode; + className?: string; + description?: ReactNode; + id: string; + intent?: ElementIntent; + onConfirm?: () => void; + title?: string; + disabled?: boolean; + disablePadding?: boolean; + confirmLabel?: string; + cancelLabel?: string; +} + +export type ConfirmationDialogData = Partial; diff --git a/src/components/ConfirmationDialog/index.ts b/src/components/ConfirmationDialog/index.ts index 1fd5bb37..0fbd5f69 100644 --- a/src/components/ConfirmationDialog/index.ts +++ b/src/components/ConfirmationDialog/index.ts @@ -1,3 +1,6 @@ -export type { ConfirmationDialogProps } from './ConfirmationDialog'; export { ConfirmationDialog } from './ConfirmationDialog'; export { useConfirmationDialog } from './ConfirmationDialog.hooks'; +export { + type ConfirmationDialogData, + type ConfirmationDialogProps, +} from './ConfirmationDialog.types'; diff --git a/src/components/ToastProvider/ToastProvider.store.ts b/src/components/ToastProvider/ToastProvider.store.ts index 43a58833..09626817 100644 --- a/src/components/ToastProvider/ToastProvider.store.ts +++ b/src/components/ToastProvider/ToastProvider.store.ts @@ -1,7 +1,7 @@ import { Store } from '@tanstack/store'; interface ToastItem { - description?: string; + description?: string | string[]; icon?: JSX.Element; severity: ToastSeverity; title: string; diff --git a/src/components/TournamentActionsProvider/TournamentActionsProvider.context.ts b/src/components/TournamentActionsProvider/TournamentActionsProvider.context.ts new file mode 100644 index 00000000..123e1194 --- /dev/null +++ b/src/components/TournamentActionsProvider/TournamentActionsProvider.context.ts @@ -0,0 +1,12 @@ +import { createContext, MouseEvent } from 'react'; + +import { TournamentActionKey } from '~/api'; + +export type Action = { + handler: (e: MouseEvent) => void; + label: string; +}; + +export type TournamentActions = Partial>; + +export const TournamentActionsContext = createContext(null); diff --git a/src/components/TournamentActionsProvider/TournamentActionsProvider.hooks.tsx b/src/components/TournamentActionsProvider/TournamentActionsProvider.hooks.tsx new file mode 100644 index 00000000..e2110bd4 --- /dev/null +++ b/src/components/TournamentActionsProvider/TournamentActionsProvider.hooks.tsx @@ -0,0 +1,229 @@ +import { useContext } from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; + +import { TournamentActionKey } from '~/api'; +import { useAuth } from '~/components/AuthProvider'; +import { ConfirmationDialogData } from '~/components/ConfirmationDialog'; +import { Warning } from '~/components/generic/Warning'; +import { toast } from '~/components/ToastProvider'; +import { useTournament } from '~/components/TournamentProvider'; +import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; +import { useGetTournamentPairings } from '~/services/tournamentPairings'; +import { + useDeleteTournament, + useEndTournament, + useEndTournamentRound, + useGetTournamentOpenRound, + usePublishTournament, + useStartTournament, + useStartTournamentRound, +} from '~/services/tournaments'; +import { PATHS } from '~/settings'; +import { validateConfigureRound } from './utils/validateConfigureRound'; +import { + Action, + TournamentActions, + TournamentActionsContext, +} from './TournamentActionsProvider.context'; + +export const useTournamentActions = () => { + const context = useContext(TournamentActionsContext); + if (!context) { + throw Error('useTournamentActions must be used within a !'); + } + return context; +}; + +type ActionDefinition = Action & { + key: TournamentActionKey; + available: boolean; +}; + +export const useActions = (openDialog: (data?: ConfirmationDialogData) => void): TournamentActions => { + const user = useAuth(); + const tournament = useTournament(); + + // ---- HANDLERS ---- + const navigate = useNavigate(); + const configureTournamentRound = (): void => { + navigate(generatePath(PATHS.tournamentPairings, { id: tournament._id })); + }; + const { mutation: deleteTournament } = useDeleteTournament({ + onSuccess: (): void => { + toast.success(`${tournament.title} deleted!`); + navigate(PATHS.tournaments); + }, + }); + + const { mutation: publishTournament } = usePublishTournament({ + onSuccess: (): void => { + toast.success(`${tournament.title} is now published!`); + }, + }); + + const { mutation: startTournament } = useStartTournament({ + onSuccess: (): void => { + toast.success(`${tournament.title} started!`); + }, + }); + + const { mutation: startTournamentRound } = useStartTournamentRound({ + onSuccess: (): void => { + toast.success(`Round ${currentRoundLabel} started!`); + }, + }); + + const { mutation: endTournament } = useEndTournament({ + onSuccess: (): void => { + toast.success(`${tournament.title} completed!`); + }, + }); + + const { mutation: endTournamentRound } = useEndTournamentRound({ + onSuccess: (): void => { + toast.success(`Round ${currentRoundLabel} completed!`); + }, + }); + + // ---- DATA ---- + const { data: nextRoundPairings } = useGetTournamentPairings({ + tournamentId: tournament._id, + round: tournament.nextRound, + }); + const { data: openRound } = useGetTournamentOpenRound({ + id: tournament._id, + }); + const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ + tournamentId: tournament._id, + }); + const isOrganizer = !!user && tournament.organizerUserIds.includes(user._id); + const isBetweenRounds = tournament.status === 'active' && !openRound; + const hasNextRound = tournament.nextRound !== undefined; + + // Labels for messages: + const nextRoundLabel = (tournament.nextRound ?? 0) + 1; + const currentRoundLabel = (tournament.currentRound ?? 0) + 1; + const remainingRoundsLabel = tournament.roundCount - ((tournament.lastRound ?? -1) + 1); + + // ---- ACTIONS ---- + const actions: ActionDefinition[] = [ + { + key: TournamentActionKey.Edit, + label: 'Edit', + available: isOrganizer && ['draft', 'published'].includes(tournament.status), + handler: () => navigate(generatePath(PATHS.tournamentEdit, { id: tournament._id })), + }, + { + key: TournamentActionKey.Delete, + label: 'Delete', + available: isOrganizer && tournament.status === 'draft', + handler: () => { + // TODO: Implement confirmation dialog + deleteTournament({ id: tournament._id }); + }, + }, + { + key: TournamentActionKey.Publish, + label: 'Publish', + available: isOrganizer && tournament.status === 'draft', + handler: () => { + // TODO: Implement confirmation dialog + publishTournament({ id: tournament._id }); + }, + }, + { + key: TournamentActionKey.Start, + label: 'Start', + available: isOrganizer && tournament.status === 'published', + handler: () => { + // TODO: Implement confirmation dialog + startTournament({ id: tournament._id }); + }, + }, + { + key: TournamentActionKey.ConfigureRound, + label: `Configure Round ${nextRoundLabel}`, + available: isOrganizer && isBetweenRounds && hasNextRound && nextRoundPairings?.length === 0, + handler: () => { + const { errors, warnings } = validateConfigureRound(tournament, tournamentCompetitors); + if (errors.length) { + return toast.error('Cannot Configure Round', { + description: errors, + }); + } + if (warnings.length) { + openDialog({ + title: `Configure Round ${nextRoundLabel}`, + children: warnings.map((warning, i) => ( + {warning} + )), + confirmLabel: 'Proceed', + onConfirm: () => configureTournamentRound(), + }); + } else { + configureTournamentRound(); + } + }, + }, + { + key: TournamentActionKey.StartRound, + label: `Start Round ${nextRoundLabel}`, + available: isOrganizer && isBetweenRounds && hasNextRound && (nextRoundPairings ?? []).length > 0, + handler: () => startTournamentRound({ id: tournament._id }), + }, + { + key: TournamentActionKey.EndRound, + label: `End Round ${currentRoundLabel}`, + available: isOrganizer && !!openRound, + handler: () => { + if (openRound && openRound.matchResultsProgress.remaining > 0) { + openDialog({ + title: 'Warning!', + description: ( + <> + {` + Are you sure you want to end round ${currentRoundLabel}? + There are still ${openRound.matchResultsProgress.remaining} + matches remaining to be checked in. + `} + Once the round is ended, it cannot be repeated! + + ), + confirmLabel: 'End Round', + onConfirm: () => endTournamentRound({ id: tournament._id }), + }); + } else { + endTournamentRound({ id: tournament._id }); + } + }, + }, + { + key: TournamentActionKey.End, + label: 'End Tournament', + available: isOrganizer && isBetweenRounds, + handler: () => { + if (tournament.nextRound !== undefined && tournament.nextRound < tournament.roundCount) { + openDialog({ + title: 'Warning!', + description: ( + <> + {`Are you sure you want to end ${tournament.title}? There are still ${remainingRoundsLabel} rounds remaining.`} + Once the tournament is ended, it cannot be restarted! + + ), + onConfirm: () => endTournament({ id: tournament._id }), + confirmLabel: 'End Tournament', + intent: 'danger', + }); + } else { + endTournament({ id: tournament._id }); + } + }, + }, + ]; + + return actions.filter(({ available }) => available).reduce((acc, { key, ...action }) => ({ + ...acc, + [key]: action, + }), {} as TournamentActions); +}; diff --git a/src/components/TournamentActionsProvider/TournamentActionsProvider.tsx b/src/components/TournamentActionsProvider/TournamentActionsProvider.tsx new file mode 100644 index 00000000..0bf9d498 --- /dev/null +++ b/src/components/TournamentActionsProvider/TournamentActionsProvider.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react'; + +import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; +import { TournamentActionsContext } from './TournamentActionsProvider.context'; +import { useActions } from './TournamentActionsProvider.hooks'; + +export interface TournamentActionsProviderProps { + children: ReactNode; +} + +export const TournamentActionsProvider = ({ + children, +}: TournamentActionsProviderProps) => { + const { id, open } = useConfirmationDialog(); + const actions = useActions(open); + return ( + + {children} + + + ); +}; diff --git a/src/components/TournamentActionsProvider/index.ts b/src/components/TournamentActionsProvider/index.ts new file mode 100644 index 00000000..8321c11a --- /dev/null +++ b/src/components/TournamentActionsProvider/index.ts @@ -0,0 +1,2 @@ +export { TournamentActionsProvider } from './TournamentActionsProvider'; +export { useTournamentActions } from './TournamentActionsProvider.hooks'; diff --git a/src/components/TournamentActionsProvider/utils/validateConfigureRound.tsx b/src/components/TournamentActionsProvider/utils/validateConfigureRound.tsx new file mode 100644 index 00000000..08111e89 --- /dev/null +++ b/src/components/TournamentActionsProvider/utils/validateConfigureRound.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from 'react'; + +import { Tournament, TournamentCompetitor } from '~/api'; +import { IdentityBadge } from '~/components/IdentityBadge'; +import { getTournamentCompetitorDisplayName } from '~/utils/common/getTournamentCompetitorDisplayName'; + +export const validateConfigureRound = ( + tournament: Tournament, + tournamentCompetitors: TournamentCompetitor[] = [], +): { errors: string[], warnings: ReactNode[] } => { + const round = (tournament.lastRound ?? -1) + 2; + const { active, inactive } = tournamentCompetitors.reduce((acc, c) => { + const key = c.active ? 'active' : 'inactive'; + acc[key].push(c); + return acc; + }, { active: [] as TournamentCompetitor[], inactive: [] as TournamentCompetitor[] }); + + const errors: string[] = []; + const warnings: ReactNode[] = []; + + if (active.length < 2) { + errors.push('Please ensure at least 2 competitors are active.'); + } + if (active.length > tournament.maxCompetitors) { + errors.push(` + There are too many active competitors. + Please disable ${active.length - tournament.maxCompetitors} to proceed. + `); + } + for (const competitor of active) { + const activePlayers = competitor.players.filter(({ active }) => active); + if (activePlayers.length > tournament.competitorSize) { + errors.push(`${getTournamentCompetitorDisplayName(competitor)} has too many active players.`); + } + if (activePlayers.length < tournament.competitorSize) { + errors.push(`${getTournamentCompetitorDisplayName(competitor)} has too few active players.`); + } + } + if (inactive.length > 0) { + warnings.push( + <> +

+ {` + The following ${tournament.useTeams ? ('team' + ((inactive.length ?? 0) > 1 ? 's are' : ' is')) : 'player(s)'} + not listed as checked in and will not be included in the pairing process for round ${round}. + `} +

+ {inactive.map((tournamentCompetitor) => ( + + ))} + , + ); + } + if (active.length % 2) { + warnings.push( +

+ {` + There is an odd number of competitors, so one competitor will remain unpaired. As + tournament organizer, you will need to submit match results for the + ${tournament.useTeams ? 'team' : 'player'} with a bye, with the desired outcome. + `} +

, + ); + } + return { errors, warnings }; +}; diff --git a/src/components/TournamentCard/TournamentCard.tsx b/src/components/TournamentCard/TournamentCard.tsx index 1f8e11fe..0c5f3e77 100644 --- a/src/components/TournamentCard/TournamentCard.tsx +++ b/src/components/TournamentCard/TournamentCard.tsx @@ -4,6 +4,7 @@ import { ChevronRight } from 'lucide-react'; import { Tournament } from '~/api'; import { useAuth } from '~/components/AuthProvider'; import { Button } from '~/components/generic/Button'; +import { TournamentActionsProvider } from '~/components/TournamentActionsProvider'; import { TournamentContextMenu } from '~/components/TournamentContextMenu'; import { TournamentInfoBlock } from '~/components/TournamentInfoBlock/'; import { TournamentProvider } from '~/components/TournamentProvider'; @@ -43,32 +44,34 @@ export const TournamentCard = ({ return ( -
-
- {tournament?.logoUrl && ( - {tournament.title} - )} -
-
-

{tournament.title}

-
- {showContextMenu && ( - + +
+
+ {tournament?.logoUrl && ( + {tournament.title} )} -
+
+

{tournament.title}

+
+ {showContextMenu && ( + )} - - + +
+ +
- - -
+ ); }; diff --git a/src/components/TournamentContextMenu/TournamentContextMenu.tsx b/src/components/TournamentContextMenu/TournamentContextMenu.tsx index 751a81ce..0dbe0c83 100644 --- a/src/components/TournamentContextMenu/TournamentContextMenu.tsx +++ b/src/components/TournamentContextMenu/TournamentContextMenu.tsx @@ -1,26 +1,8 @@ -import { useRef } from 'react'; -import { generatePath, useNavigate } from 'react-router-dom'; import { Ellipsis } from 'lucide-react'; -import { useAuth } from '~/components/AuthProvider'; import { Button } from '~/components/generic/Button'; import { PopoverMenu } from '~/components/generic/PopoverMenu'; -import { toast } from '~/components/ToastProvider'; -import { ConfirmConfigureRoundDialog } from '~/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog'; -import { ConfirmConfigureRoundDialogHandle } from '~/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog'; -import { getRemainingRequiredMatchResults } from '~/components/TournamentContextMenu/TournamentContextMenu.utils'; -import { useTournament } from '~/components/TournamentProvider'; -import { useGetTournamentPairings } from '~/services/tournamentPairings'; -import { - useDeleteTournament, - useEndTournament, - useEndTournamentRound, - useGetTournamentOpenRound, - usePublishTournament, - useStartTournament, - useStartTournamentRound, -} from '~/services/tournaments'; -import { PATHS } from '~/settings'; +import { useTournamentActions } from '~/components/TournamentActionsProvider'; import { ElementSize } from '~/types/componentLib'; export interface TournamentContextMenuProps { @@ -34,127 +16,19 @@ export const TournamentContextMenu = ({ size = 'normal', variant = 'secondary', }: TournamentContextMenuProps): JSX.Element | null => { - const navigate = useNavigate(); - - const user = useAuth(); - const { - _id: id, - status, - currentRound, - lastRound, - title, - organizerUserIds, - } = useTournament(); - const nextRound = (lastRound ?? -1) + 1; - const nextRoundLabel = nextRound + 1; - const currentRoundLabel = (currentRound ?? 0) + 1; - - const { data: openRound } = useGetTournamentOpenRound({ id }); - const { data: nextRoundPairings } = useGetTournamentPairings({ - tournamentId: id, - round: nextRound, - }); - - const { mutation: deleteTournament } = useDeleteTournament({ - onSuccess: (): void => { - toast.success(`${title} deleted!`); - navigate(PATHS.tournaments); - }, - }); - - const { mutation: publishTournament } = usePublishTournament({ - onSuccess: (): void => { - toast.success(`${title} is now published!`); - }, - }); - - const { mutation: startTournament } = useStartTournament({ - onSuccess: (): void => { - toast.success(`${title} started!`); - }, - }); - - const { mutation: startTournamentRound } = useStartTournamentRound({ - onSuccess: (): void => { - toast.success(`Round ${currentRoundLabel} started!`); - }, - }); - - const { mutation: endTournament } = useEndTournament({ - onSuccess: (): void => { - toast.success(`${title} completed!`); - }, - }); - - const { mutation: endTournamentRound } = useEndTournamentRound({ - onSuccess: (): void => { - toast.success(`Round ${currentRoundLabel} completed!`); - }, - }); - - const confirmConfigureRoundDialogRef = useRef(null); - - const menuItems = [ - { - label: 'Edit', - onClick: () => navigate(generatePath(PATHS.tournamentEdit, { id })), - visible: status !== 'active' && status !== 'archived', - }, - { - label: 'Delete', - onClick: () => deleteTournament({ id }), - // TODO: Implement confirmation dialog - visible: status !== 'active' && status !== 'archived', - }, - { - label: 'Publish', - onClick: () => publishTournament({ id }), - // TODO: Implement confirmation dialog - visible: status === 'draft', - }, - { - label: 'Start', - onClick: () => startTournament({ id }), - visible: status === 'published', - }, - { - label: `Configure Round ${nextRoundLabel}`, - onClick: () => confirmConfigureRoundDialogRef.current?.open(), - visible: status === 'active' && !openRound && !nextRoundPairings?.length, - }, - { - label: `Start Round ${nextRoundLabel}`, - onClick: () => startTournamentRound({ id }), - visible: status === 'active' && !openRound && nextRoundPairings?.length, - }, - { - label: `End Round ${(currentRound ?? 0) + 1}`, - onClick: () => endTournamentRound({ id }), - visible: openRound && getRemainingRequiredMatchResults(openRound) === 0, - }, - { - label: 'End Tournament', - onClick: () => endTournament({ id }), - // TODO: More checks, show confirmation dialog if not complete - visible: status === 'active', - }, - ]; - - const visibleMenuItems = menuItems.filter((item) => item.visible); - - // TODO: Make better check for showing context menu - const showContextMenu = user && organizerUserIds.includes(user._id) && visibleMenuItems.length; - if (!showContextMenu) { + const actions = useTournamentActions(); + const visibleMenuItems = Object.values(actions).map(({ label, handler }) => ({ + label, + onClick: handler, + })); + if (!visibleMenuItems.length) { return null; } return ( - <> - - - - - + + + ); }; diff --git a/src/components/TournamentContextMenu/TournamentContextMenu.utils.ts b/src/components/TournamentContextMenu/TournamentContextMenu.utils.ts deleted file mode 100644 index 821a2059..00000000 --- a/src/components/TournamentContextMenu/TournamentContextMenu.utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TournamentOpenRound } from '~/services/tournaments'; - -export const getRemainingRequiredMatchResults = ( - openRound?: TournamentOpenRound, -): number | null => { - if (!openRound) { - return null; - } - return openRound.matchResultsProgress.required - openRound.matchResultsProgress.submitted; -}; diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss deleted file mode 100644 index 62aea60f..00000000 --- a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use "/src/style/flex"; - -.ConfirmConfigureRoundDialog { - @include flex.column; -} diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx deleted file mode 100644 index 546fe582..00000000 --- a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - forwardRef, - ReactNode, - useImperativeHandle, - useMemo, -} from 'react'; -import { generatePath, useNavigate } from 'react-router-dom'; -import clsx from 'clsx'; - -import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; -import { Warning } from '~/components/generic/Warning'; -import { toast } from '~/components/ToastProvider'; -import { useTournament } from '~/components/TournamentProvider'; -import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; -import { PATHS } from '~/settings'; -import { getTournamentCompetitorDisplayName } from '~/utils/common/getTournamentCompetitorDisplayName'; -import { getWarnings, sortCompetitorsByActive } from './ConfirmConfigureRoundDialog.utils'; - -import styles from './ConfirmConfigureRoundDialog.module.scss'; - -export interface ConfirmConfigureRoundDialogProps { - className?: string; -} - -export interface ConfirmConfigureRoundDialogHandle { - open: () => void; -} - -export const ConfirmConfigureRoundDialog = forwardRef(({ - className, -}: ConfirmConfigureRoundDialogProps, ref): JSX.Element => { - const tournament = useTournament(); - const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ - tournamentId: tournament._id, - }); - const navigate = useNavigate(); - const { id, open } = useConfirmationDialog(); - const proceed = (): void => { - navigate(generatePath(PATHS.tournamentPairings, { id: tournament._id })); - }; - - const { active: activeCompetitors } = sortCompetitorsByActive(tournamentCompetitors ?? []); - const warnings: ReactNode[] = useMemo(() => getWarnings(tournament, tournamentCompetitors ?? []), [ - tournament, - tournamentCompetitors, - ]); - - useImperativeHandle(ref, () => ({ - open: () => { - if (activeCompetitors.length < 2) { - return toast.error('Cannot Configure Round', { - description: 'Please ensure at least 2 competitors are active.', - }); - } - if (activeCompetitors.length > tournament.maxCompetitors) { - return toast.error('Cannot Configure Round', { - description: `There are too many active competitors. Please disable ${activeCompetitors.length - tournament.maxCompetitors} to proceed.`, - }); - } - for (const competitor of activeCompetitors) { - const activePlayers = competitor.players.filter(({ active }) => active); - if (activePlayers.length > tournament.competitorSize) { - return toast.error('Cannot Configure Round', { - description: `${getTournamentCompetitorDisplayName(competitor)} has too many active players.`, - }); - } - if (activePlayers.length < tournament.competitorSize) { - return toast.error('Cannot Configure Round', { - description: `${getTournamentCompetitorDisplayName(competitor)} has too few active players.`, - }); - } - } - - if (warnings.length) { - open(); - } else { - proceed(); - } - }, - })); - return ( - - {warnings.map((warning, i) => ( - - {warning} - - ))} - - ); -}); diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx deleted file mode 100644 index c7ee414b..00000000 --- a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { ReactNode } from 'react'; - -import { Tournament, TournamentCompetitor } from '~/api'; -import { IdentityBadge } from '~/components/IdentityBadge'; - -export const sortCompetitorsByActive = (tournamentCompetitors: TournamentCompetitor[]) => ( - tournamentCompetitors.reduce( - (acc, c) => { - const key = c.active ? 'active' : 'inactive'; - acc[key].push(c); - return acc; - }, - { active: [] as TournamentCompetitor[], inactive: [] as TournamentCompetitor[] }, - ) -); - -export const getWarnings = (tournament: Tournament, tournamentCompetitors: TournamentCompetitor[]): ReactNode[] => { - const round = (tournament.lastRound ?? -1) + 2; - const { active, inactive } = sortCompetitorsByActive(tournamentCompetitors); - const warnings: ReactNode[] = []; - if (inactive.length > 0) { - warnings.push( - <> -

- {`The following ${tournament.useTeams ? ('team' + ((inactive.length ?? 0) > 1 ? 's are' : ' is')) : 'player(s)'} not listed as checked in and will not be included in the pairing process for round ${round}.`} -

- {inactive.map((tournamentCompetitor) => ( - - ))} - , - ); - } - if (active.length % 2) { - warnings.push( -

- {`There is an odd number of competitors, so one competitor will remain unpaired. As tournament organizer, you will need to submit match results for the ${tournament.useTeams ? 'team' : 'player'} with a bye, with the desired outcome.`} -

, - ); - } - return warnings; -}; diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts deleted file mode 100644 index 61bf2d13..00000000 --- a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - ConfirmConfigureRoundDialog, - type ConfirmConfigureRoundDialogHandle, - type ConfirmConfigureRoundDialogProps, -} from './ConfirmConfigureRoundDialog'; diff --git a/src/components/TournamentContextMenu/index.ts b/src/components/TournamentContextMenu/index.ts index 1ce08722..d1c00880 100644 --- a/src/components/TournamentContextMenu/index.ts +++ b/src/components/TournamentContextMenu/index.ts @@ -1,5 +1 @@ -export { - ConfirmConfigureRoundDialog, - type ConfirmConfigureRoundDialogHandle, -} from './components/ConfirmConfigureRoundDialog'; export { TournamentContextMenu } from './TournamentContextMenu'; diff --git a/src/components/generic/Dialog/DialogDescription.module.scss b/src/components/generic/Dialog/DialogDescription.module.scss index e181637b..602b5303 100644 --- a/src/components/generic/Dialog/DialogDescription.module.scss +++ b/src/components/generic/Dialog/DialogDescription.module.scss @@ -11,4 +11,8 @@ @include flex.column($gap: 0.5rem); padding: 0 var(--modal-inner-gutter); + + strong { + font-weight: 500; + } } diff --git a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx index d9c26eb2..90563879 100644 --- a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx +++ b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx @@ -16,6 +16,7 @@ import { } from '~/components/generic/Tabs'; import { NotFoundView } from '~/components/NotFoundView'; import { PageWrapper } from '~/components/PageWrapper'; +import { TournamentActionsProvider } from '~/components/TournamentActionsProvider'; import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; import { TournamentContextMenu } from '~/components/TournamentContextMenu'; import { TournamentProvider } from '~/components/TournamentProvider'; @@ -105,40 +106,42 @@ export const TournamentDetailPage = (): JSX.Element => { return ( - - } bannerBackgroundUrl={tournament.bannerUrl}> -
- {showInfoSidebar && ( -
- -
- )} - -
- {tabs.length > 1 && ( - - )} - -
- - - - - - - - - - - - - - - -
-
-
-
+ + + } bannerBackgroundUrl={tournament.bannerUrl}> +
+ {showInfoSidebar && ( +
+ +
+ )} + +
+ {tabs.length > 1 && ( + + )} + +
+ + + + + + + + + + + + + + + +
+
+
+
+
); }; diff --git a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx index 9808731f..a9d2df5b 100644 --- a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx @@ -1,15 +1,11 @@ -import { - ReactElement, - useRef, - useState, -} from 'react'; +import { ReactElement, useState } from 'react'; import clsx from 'clsx'; import { Zap } from 'lucide-react'; import { Button } from '~/components/generic/Button'; import { InputSelect } from '~/components/generic/InputSelect'; import { Table } from '~/components/generic/Table'; -import { ConfirmConfigureRoundDialog, ConfirmConfigureRoundDialogHandle } from '~/components/TournamentContextMenu'; +import { useTournamentActions } from '~/components/TournamentActionsProvider/TournamentActionsProvider.hooks'; import { useTournament } from '~/components/TournamentProvider'; import { useGetTournamentPairings } from '~/services/tournamentPairings'; import { TournamentDetailCard } from '../TournamentDetailCard'; @@ -25,16 +21,19 @@ export interface TournamentPairingsCardProps { export const TournamentPairingsCard = ({ className, }: TournamentPairingsCardProps): JSX.Element => { - const { _id: tournamentId, lastRound } = useTournament(); + const { _id: tournamentId, lastRound, roundCount } = useTournament(); + const actions = useTournamentActions(); - const roundIndexes = lastRound !== undefined ? Array.from({ length: lastRound + 2 }, (_, i) => i) : [0]; + const roundIndexes = lastRound !== undefined ? Array.from({ + length: Math.min(lastRound + 2, roundCount), + }, (_, i) => i) : [0]; const [round, setRound] = useState(roundIndexes.length - 1); + const { data: tournamentPairings, loading } = useGetTournamentPairings({ tournamentId, round, }); - const confirmConfigureRoundDialogRef = useRef(null); const columns = getTournamentPairingTableConfig(); const rows = (tournamentPairings || []); @@ -55,10 +54,6 @@ export const TournamentPairingsCard = ({ />, ]; - const handleConfigure = (): void => { - confirmConfigureRoundDialogRef.current?.open(); - }; - return ( <> }> - + {actions?.configureRound && ( + + )} ) : (
) )} - - ); }; From 76995bad85efc22266fefa3c1b688b645b56e118 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 15 Jul 2025 08:48:48 +0200 Subject: [PATCH 26/31] chore: Improve mock match result creation --- .../fowV4/createMockFowV4MatchResultData.ts | 31 +++++++ convex/_generated/api.d.ts | 2 + convex/_model/matchResults/index.ts | 1 + .../mutations/createMatchResult.ts | 4 +- .../utils/createTestTournamentMatchResults.ts | 89 +++++++++++++------ 5 files changed, 98 insertions(+), 29 deletions(-) create mode 100644 convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts diff --git a/convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts b/convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts new file mode 100644 index 00000000..bd97b892 --- /dev/null +++ b/convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts @@ -0,0 +1,31 @@ +import { CreateMatchResultArgs } from '../../_model/matchResults'; + +export const createMockFowV4MatchResultData = ( + data: Partial>, +): CreateMatchResultArgs => { + const outcomeType = Math.random() > 0.25 ? 'objective_taken' : 'time_out'; + return { + playedAt: new Date().toISOString(), + details: { + attacker: 0, + firstTurn: 0, + missionId: 'flames_of_war_v4::mission::2023_04_spearpoint', + outcomeType, + player0BattlePlan: 'attack', + player0UnitsLost: Math.round(Math.random() * 5) + 2, + player1BattlePlan: 'attack', + player1UnitsLost: Math.round(Math.random() * 5) + 2, + turnsPlayed: Math.round(Math.random() * 5) + 2, + winner: outcomeType === 'time_out' ? -1 : (Math.random() > 0.5 ? 1 : 0), + }, + gameSystemConfig: { + points: 100, + eraId: 'flames_of_war_v4::era::late_war', + lessonsFromTheFrontVersionId: 'flames_of_war_v4::lessons_from_the_front_version::2024_03', + missionPackId: 'flames_of_war_v4::mission_pack::2023_04', + missionMatrixId: 'flames_of_war_v4::mission_matrix::2023_04_extended', + }, + gameSystemId: 'flames_of_war_v4', + ...data, + }; +}; diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6ec3b664..fe317253 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type { FilterApi, FunctionReference, } from "convex/server"; +import type * as _fixtures_fowV4_createMockFowV4MatchResultData from "../_fixtures/fowV4/createMockFowV4MatchResultData.js"; import type * as _model_common__helpers_checkAuth from "../_model/common/_helpers/checkAuth.js"; import type * as _model_common__helpers_clamp from "../_model/common/_helpers/clamp.js"; import type * as _model_common__helpers_filterWithSearchTerm from "../_model/common/_helpers/filterWithSearchTerm.js"; @@ -199,6 +200,7 @@ import type * as utils from "../utils.js"; * ``` */ declare const fullApi: ApiFromModules<{ + "_fixtures/fowV4/createMockFowV4MatchResultData": typeof _fixtures_fowV4_createMockFowV4MatchResultData; "_model/common/_helpers/checkAuth": typeof _model_common__helpers_checkAuth; "_model/common/_helpers/clamp": typeof _model_common__helpers_clamp; "_model/common/_helpers/filterWithSearchTerm": typeof _model_common__helpers_filterWithSearchTerm; diff --git a/convex/_model/matchResults/index.ts b/convex/_model/matchResults/index.ts index 104b93c1..d8a0a47c 100644 --- a/convex/_model/matchResults/index.ts +++ b/convex/_model/matchResults/index.ts @@ -25,6 +25,7 @@ export { } from './mutations/addPhotoToMatchResult'; export { createMatchResult, + type CreateMatchResultArgs, createMatchResultArgs, } from './mutations/createMatchResult'; export { diff --git a/convex/_model/matchResults/mutations/createMatchResult.ts b/convex/_model/matchResults/mutations/createMatchResult.ts index 35ff9f49..4bcd7a41 100644 --- a/convex/_model/matchResults/mutations/createMatchResult.ts +++ b/convex/_model/matchResults/mutations/createMatchResult.ts @@ -11,6 +11,8 @@ export const createMatchResultArgs = v.object({ ...editableFields, }); +export type CreateMatchResultArgs = Infer; + /** * Creates a new match result. * @@ -20,7 +22,7 @@ export const createMatchResultArgs = v.object({ */ export const createMatchResult = async ( ctx: MutationCtx, - args: Infer, + args: CreateMatchResultArgs, ): Promise> => { const userId = await checkAuth(ctx); diff --git a/convex/_model/utils/createTestTournamentMatchResults.ts b/convex/_model/utils/createTestTournamentMatchResults.ts index f84d9b6e..b75709ab 100644 --- a/convex/_model/utils/createTestTournamentMatchResults.ts +++ b/convex/_model/utils/createTestTournamentMatchResults.ts @@ -1,5 +1,7 @@ import { Infer, v } from 'convex/values'; +import { createMockFowV4MatchResultData } from '../../_fixtures/fowV4/createMockFowV4MatchResultData'; +import { Doc, Id } from '../../_generated/dataModel'; import { MutationCtx } from '../../_generated/server'; export const createTestTournamentMatchResultsArgs = v.object({ @@ -28,38 +30,69 @@ export const createTestTournamentMatchResults = async ( throw new Error('No pairings to create results for!'); } tournamentPairings.forEach(async (pairing) => { - const tournamentCompetitor0 = await ctx.db.get(pairing.tournamentCompetitor0Id); - const tournamentCompetitor1 = pairing.tournamentCompetitor1Id ? await ctx.db.get(pairing.tournamentCompetitor1Id) : null; - if (!tournamentCompetitor0) { - throw new Error('Pairing needs at least 1 competitor!'); - } - const tournamentCompetitor0UserIds = tournamentCompetitor0.players.filter((player) => player.active).map((player) => player.userId); - const tournamentCompetitor1UserIds = tournamentCompetitor1 ? tournamentCompetitor1.players.filter((player) => player.active).map((player) => player.userId) : []; - for (let i = 0; i < tournament.competitorSize; i++) { - const outcomeType = Math.random() > 0.25 ? 'objective_taken' : 'time_out'; - await ctx.db.insert('matchResults', { + const existingMatchResults = await ctx.db.query('matchResults') + .withIndex('by_tournament_pairing_id', (q) => q.eq('tournamentPairingId', pairing._id)) + .collect(); + + const matchResultIds = existingMatchResults.map((matchResult) => matchResult._id); + const usedPlayerIds = existingMatchResults.reduce((acc, result) => { + if (result.player0UserId) { + acc.push(result.player0UserId); + } + if (result.player1UserId) { + acc.push(result.player1UserId); + } + return acc; + }, [] as Id<'users'>[]); + + let i = 0; + while (matchResultIds.length < tournament.competitorSize) { + i += 1; + + if (i > tournament.competitorSize * 2) { + throw new Error('Adding way too many match results! Something is wrong!'); + } + + const playerData: Pick, 'player0UserId' | 'player1UserId' | 'player1Placeholder' | 'player0Placeholder'> = {}; + const tournamentCompetitor0 = await ctx.db.get(pairing.tournamentCompetitor0Id); + const tournamentCompetitor0UserIds = tournamentCompetitor0 ? tournamentCompetitor0.players.filter((player) => ( + player.active && !usedPlayerIds.includes(player.userId) + )).map((player) => player.userId) : []; + const player0UserId = tournamentCompetitor0UserIds.pop(); + if (player0UserId) { + playerData.player0UserId = player0UserId; + } else { + playerData.player0Placeholder = 'Bye'; + } + + if (pairing.tournamentCompetitor1Id) { + const tournamentCompetitor1 = await ctx.db.get(pairing.tournamentCompetitor1Id); + const tournamentCompetitor1UserIds = tournamentCompetitor1 ? tournamentCompetitor1.players.filter((player) => ( + player.active && !usedPlayerIds.includes(player.userId) + )).map((player) => player.userId) : []; + const player1UserId = tournamentCompetitor1UserIds.pop(); + playerData.player1UserId = player1UserId; + } else { + playerData.player1Placeholder = 'Bye'; + } + + const matchResultId = await ctx.db.insert('matchResults', createMockFowV4MatchResultData({ + ...playerData, tournamentPairingId: pairing._id, tournamentId: tournament._id, - player0UserId: tournamentCompetitor0UserIds[i], - player1UserId: tournamentCompetitor1UserIds[i], - player0Confirmed: true, - player1Confirmed: true, - playedAt: new Date().toISOString(), - details: { - attacker: 0, - firstTurn: 0, - missionId: 'flames_of_war_v4::mission::2023_04_spearpoint', - outcomeType, - player0BattlePlan: 'attack', - player0UnitsLost: Math.round(Math.random() * 5) + 2, - player1BattlePlan: 'attack', - player1UnitsLost: Math.round(Math.random() * 5) + 2, - turnsPlayed: Math.round(Math.random() * 5) + 2, - winner: outcomeType === 'time_out' ? -1 : (Math.random() > 0.5 ? 1 : 0), - }, gameSystemConfig: tournament.gameSystemConfig, gameSystemId: tournament.gameSystemId, - }); + })); + + if (matchResultId) { + matchResultIds.push(matchResultId); + if (playerData.player0UserId) { + usedPlayerIds.push(playerData.player0UserId); + } + if (playerData.player1UserId) { + usedPlayerIds.push(playerData.player1UserId); + } + } } }); }; From 4e0febcd2439e60070ae0c65d9141fb466e584ca Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 15 Jul 2025 08:49:44 +0200 Subject: [PATCH 27/31] feat: Allow matchResult.playedAt to be date string or number --- convex/_model/matchResults/fields.ts | 5 ++--- .../FowV4MatchResultForm/FowV4MatchResultForm.schema.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/convex/_model/matchResults/fields.ts b/convex/_model/matchResults/fields.ts index 158c423e..1a2dab43 100644 --- a/convex/_model/matchResults/fields.ts +++ b/convex/_model/matchResults/fields.ts @@ -9,6 +9,7 @@ export const editableFields = { tournamentPairingId: v.optional(v.id('tournamentPairings')), // Denormalized so that we can filter match results by tournament. // The duplicate data is worth the efficiency in querying. + // Calculated from tournamentPairingId. tournamentId: v.optional(v.id('tournaments')), // Players @@ -18,13 +19,11 @@ export const editableFields = { player1Placeholder: v.optional(v.string()), // General - playedAt: v.string(), + playedAt: v.union(v.string(), v.number()), details: v.union(fowV4MatchResultDetails), // Game System gameSystemConfig: v.union(fowV4GameSystemConfig), - // Denormalized so that we can filter tournaments by game system. - // The duplicate data is worth the efficiency in querying. gameSystemId: gameSystemId, photoIds: v.optional(v.array(v.id('photos'))), diff --git a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts index 62f608a9..6db6607f 100644 --- a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts +++ b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts @@ -42,7 +42,7 @@ export const fowV4MatchResultFormSchema = z.object({ // Non-editable gameSystemId: z.string().transform((val) => val as GameSystemId), - playedAt: z.string(), // TODO: not visible, enable later + playedAt: z.union([z.string(), z.number()]), // TODO: not visible, enable later }).superRefine((values, ctx) => { if (values.details.outcomeType !== 'time_out' && values.details.winner === undefined) { ctx.addIssue({ From 8bf4fbc838fffec20e0302c0d0625386f9101796 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 15 Jul 2025 19:51:29 +0200 Subject: [PATCH 28/31] feat: #115 Hide match result battle plans (#116) --- convex/_generated/api.d.ts | 2 + .../fowV4/calculateFowV4MatchResultScore.ts | 3 +- .../fowV4/extractFowV4MatchResultBaseStats.ts | 3 +- .../checkMatchResultBattlePlanVisibility.ts | 55 +++++++++++++++ .../_helpers/deepenMatchResult.ts | 11 ++- .../matchResults/queries/getMatchResults.ts | 2 +- .../queries/getMatchResultsByTournament.ts | 1 + .../getMatchResultsByTournamentPairing.ts | 1 + .../getMatchResultsByTournamentRound.ts | 1 + .../FowV4MatchResultDetails.utils.ts | 4 +- .../components/MatchResultDetails.module.scss | 65 ----------------- .../components/MatchResultDetails.tsx | 69 ------------------- .../components/MatchResultDetails.utils.ts | 19 ----- 13 files changed, 77 insertions(+), 159 deletions(-) create mode 100644 convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts delete mode 100644 src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss delete mode 100644 src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx delete mode 100644 src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index fe317253..0e141492 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -53,6 +53,7 @@ import type * as _model_matchResultLikes_queries_getMatchResultLike from "../_mo import type * as _model_matchResultLikes_queries_getMatchResultLikesByMatchResult from "../_model/matchResultLikes/queries/getMatchResultLikesByMatchResult.js"; import type * as _model_matchResultLikes_queries_getMatchResultLikesByUser from "../_model/matchResultLikes/queries/getMatchResultLikesByUser.js"; import type * as _model_matchResults__helpers_checkMatchResultAuth from "../_model/matchResults/_helpers/checkMatchResultAuth.js"; +import type * as _model_matchResults__helpers_checkMatchResultBattlePlanVisibility from "../_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.js"; import type * as _model_matchResults__helpers_deepenMatchResult from "../_model/matchResults/_helpers/deepenMatchResult.js"; import type * as _model_matchResults__helpers_getShallowMatchResult from "../_model/matchResults/_helpers/getShallowMatchResult.js"; import type * as _model_matchResults_fields from "../_model/matchResults/fields.js"; @@ -240,6 +241,7 @@ declare const fullApi: ApiFromModules<{ "_model/matchResultLikes/queries/getMatchResultLikesByMatchResult": typeof _model_matchResultLikes_queries_getMatchResultLikesByMatchResult; "_model/matchResultLikes/queries/getMatchResultLikesByUser": typeof _model_matchResultLikes_queries_getMatchResultLikesByUser; "_model/matchResults/_helpers/checkMatchResultAuth": typeof _model_matchResults__helpers_checkMatchResultAuth; + "_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility": typeof _model_matchResults__helpers_checkMatchResultBattlePlanVisibility; "_model/matchResults/_helpers/deepenMatchResult": typeof _model_matchResults__helpers_deepenMatchResult; "_model/matchResults/_helpers/getShallowMatchResult": typeof _model_matchResults__helpers_getShallowMatchResult; "_model/matchResults/fields": typeof _model_matchResults_fields; diff --git a/convex/_model/fowV4/calculateFowV4MatchResultScore.ts b/convex/_model/fowV4/calculateFowV4MatchResultScore.ts index 74c2517c..21d695ba 100644 --- a/convex/_model/fowV4/calculateFowV4MatchResultScore.ts +++ b/convex/_model/fowV4/calculateFowV4MatchResultScore.ts @@ -1,4 +1,5 @@ import { Doc } from '../../_generated/dataModel'; +import { DeepMatchResult } from '../matchResults'; /** * Calculate the Victory Points (i.e. score) for a given match result. @@ -9,7 +10,7 @@ import { Doc } from '../../_generated/dataModel'; * @param matchResult - The match result to score * @returns - A tuple with the scores for player 0 and 1 respectively */ -export const calculateFowV4MatchResultScore = (matchResult: Doc<'matchResults'>): [number, number] => { +export const calculateFowV4MatchResultScore = (matchResult: Doc<'matchResults'> | DeepMatchResult): [number, number] => { // TODO: Add some guards in case matchResult is not FowV4 diff --git a/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts b/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts index 7b89c6cb..23e6f08a 100644 --- a/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts +++ b/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts @@ -1,4 +1,5 @@ import { Doc } from '../../_generated/dataModel'; +import { DeepMatchResult } from '../matchResults'; import { calculateFowV4MatchResultScore } from './calculateFowV4MatchResultScore'; import { FowV4BaseStats } from './types'; @@ -9,7 +10,7 @@ import { FowV4BaseStats } from './types'; * @returns */ -export const extractFowV4MatchResultBaseStats = (matchResult: Doc<'matchResults'>): [FowV4BaseStats, FowV4BaseStats] => { +export const extractFowV4MatchResultBaseStats = (matchResult: Doc<'matchResults'> | DeepMatchResult): [FowV4BaseStats, FowV4BaseStats] => { const score = calculateFowV4MatchResultScore(matchResult); return [ { diff --git a/convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts b/convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts new file mode 100644 index 00000000..3b31c6b8 --- /dev/null +++ b/convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts @@ -0,0 +1,55 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getTournamentShallow } from '../../../_model/tournaments'; +import { deepenTournamentPairing } from '../../tournamentPairings'; + +/** + * Checks if a match result's battle plans should be visible or not. + * + * @param ctx - Convex query context + * @param matchResult - Raw match result document + * @returns True if the battle plans should be visible, false if not + */ +export const checkMatchResultBattlePlanVisibility = async ( + ctx: QueryCtx, + matchResult: Doc<'matchResults'>, +): Promise => { + const userId = await getAuthUserId(ctx); + + // If the match result doesn't belong to a tournament pairing, battle plans should be visible: + if (!matchResult?.tournamentPairingId) { + return true; + } + + const tournamentPairing = await ctx.db.get(matchResult.tournamentPairingId); + + // If the match result's pairing has gone missing, treat it the same as a single match: + if (!tournamentPairing) { + return true; + } + const deepTournamentPairing = await deepenTournamentPairing(ctx, tournamentPairing); + const tournament = await getTournamentShallow(ctx, deepTournamentPairing.tournamentId); + + // If the match result is not from an on-going tournament, battle plans should be visible: + if (tournament?.status !== 'active') { + return true; + } + + if (userId) { + + // If the requesting user is an organizer, battle plans should be visible: + if (tournament.organizerUserIds.includes(userId)) { + return true; + } + + // If the requesting user is a player within that pairing, battle plans should be visible: + if (deepTournamentPairing.playerUserIds.includes(userId)) { + return true; + } + } + + // Hide battle plans in all other cases: + return false; +}; diff --git a/convex/_model/matchResults/_helpers/deepenMatchResult.ts b/convex/_model/matchResults/_helpers/deepenMatchResult.ts index 1ef3a542..55cd026f 100644 --- a/convex/_model/matchResults/_helpers/deepenMatchResult.ts +++ b/convex/_model/matchResults/_helpers/deepenMatchResult.ts @@ -2,6 +2,7 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { getMission } from '../../fowV4/getMission'; import { getUser } from '../../users/queries/getUser'; +import { checkMatchResultBattlePlanVisibility } from './checkMatchResultBattlePlanVisibility'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -24,19 +25,27 @@ export const deepenMatchResult = async ( const player1User = matchResult?.player1UserId ? await getUser(ctx, { id: matchResult.player1UserId, }) : null; - const mission = getMission(matchResult.details.missionId); + + // Social const comments = await ctx.db.query('matchResultComments') .withIndex('by_match_result_id',((q) => q.eq('matchResultId', matchResult._id))) .collect(); const likes = await ctx.db.query('matchResultLikes') .withIndex('by_match_result_id',((q) => q.eq('matchResultId', matchResult._id))) .collect(); + + // Details + const mission = getMission(matchResult.details.missionId); + const battlePlansVisible = await checkMatchResultBattlePlanVisibility(ctx, matchResult); + return { ...matchResult, ...(player0User ? { player0User } : {}), ...(player1User ? { player1User } : {}), details: { ...matchResult.details, + player0BattlePlan: battlePlansVisible ? matchResult.details.player0BattlePlan : undefined, + player1BattlePlan: battlePlansVisible ? matchResult.details.player1BattlePlan : undefined, missionName: mission?.displayName, }, likedByUserIds: likes.map((like) => like.userId), diff --git a/convex/_model/matchResults/queries/getMatchResults.ts b/convex/_model/matchResults/queries/getMatchResults.ts index 8bbb3e30..6381ab63 100644 --- a/convex/_model/matchResults/queries/getMatchResults.ts +++ b/convex/_model/matchResults/queries/getMatchResults.ts @@ -4,7 +4,7 @@ import { deepenMatchResult, DeepMatchResult } from '../_helpers/deepenMatchResul export const getMatchResults = async ( ctx: QueryCtx, ): Promise => { - const matchResults = await ctx.db.query('matchResults').collect(); + const matchResults = await ctx.db.query('matchResults').order('desc').collect(); return await Promise.all(matchResults.map( async (item) => await deepenMatchResult(ctx, item), )); diff --git a/convex/_model/matchResults/queries/getMatchResultsByTournament.ts b/convex/_model/matchResults/queries/getMatchResultsByTournament.ts index 2a70339a..c4231215 100644 --- a/convex/_model/matchResults/queries/getMatchResultsByTournament.ts +++ b/convex/_model/matchResults/queries/getMatchResultsByTournament.ts @@ -13,6 +13,7 @@ export const getMatchResultsByTournament = async ( ): Promise => { const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .order('desc') .collect(); return await Promise.all(matchResults.map( async (item) => await deepenMatchResult(ctx, item), diff --git a/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts b/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts index add689e3..2e0feaf9 100644 --- a/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts +++ b/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts @@ -13,6 +13,7 @@ export const getMatchResultsByTournamentPairing = async ( ): Promise => { const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_pairing_id', (q) => q.eq('tournamentPairingId', args.tournamentPairingId)) + .order('desc') .collect(); return await Promise.all(matchResults.map( async (item) => await deepenMatchResult(ctx, item), diff --git a/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts b/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts index 73b7e073..c3b5b33e 100644 --- a/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts +++ b/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts @@ -18,6 +18,7 @@ export const getMatchResultsByTournamentRound = async ( .collect(); const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .order('desc') .collect(); const filteredMatchResults = matchResults.filter((result) => ( !!tournamentPairings.find((item) => item._id === result.tournamentPairingId) diff --git a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts index 67c299fc..6116551a 100644 --- a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts +++ b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts @@ -12,10 +12,10 @@ export const formatOutcome = ( } if (details.winner !== -1 && details.outcomeType === 'force_broken') { if (details.winner === 0) { - return `${playerNames[0]} broke ${playerNames[1]}\u{2019}s formation(s).`; + return `${playerNames[0]} broke ${playerNames[1]}\u{2019}s formation.`; } if (details.winner === 1) { - return `${playerNames[1]} broke ${playerNames[0]}\u{2019}s formation(s).`; + return `${playerNames[1]} broke ${playerNames[0]}\u{2019}s formation.`; } } return 'Draw / Time Out'; diff --git a/src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss b/src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss deleted file mode 100644 index 9ab3d391..00000000 --- a/src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss +++ /dev/null @@ -1,65 +0,0 @@ -@use "/src/style/text"; -@use "/src/style/flex"; - -.Root { - @include text.ui; - - gap: 1rem; - - &[data-orientation="horizontal"] { - display: grid; - grid-template-areas: "meta meta" "set-up outcome"; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto; - - .MetaSection { - @include flex.row; - } - } - - &[data-orientation="vertical"] { - @include flex.column; - - column-gap: 1rem; - } -} - -.MetaSection { - grid-area: meta; -} - -.SetUpSection { - display: grid; - grid-area: set-up; - grid-template-columns: auto 1fr; - row-gap: 0.25rem; - column-gap: 1rem; - - h3 { - grid-column: 1/3; - } -} - -.OutcomeSection { - display: grid; - grid-area: outcome; - grid-template-columns: auto 1fr; - row-gap: 0.25rem; - column-gap: 1rem; - - h3 { - grid-column: 1/3; - } -} - -.DetailLabel { - @include text.ui($muted: true); - - display: inline-block; -} - -.DetailValue { - @include text.ui; - - display: inline-block; -} diff --git a/src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx b/src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx deleted file mode 100644 index 19521b3c..00000000 --- a/src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import clsx from 'clsx'; - -import { fowV4BattlePlanOptions } from '~/api'; -import { useMatchResult } from '~/components/MatchResultProvider'; -import { useElementSize } from '~/hooks/useElementSize'; -import { formatOutcome } from '~/pages/MatchResultDetailPage/components/MatchResultDetails.utils'; - -import styles from './MatchResultDetails.module.scss'; - -export interface MatchResultDetailsProps { - className?: string; -} - -export const MatchResultDetails = ({ - className, -}: MatchResultDetailsProps): JSX.Element => { - const matchResult = useMatchResult(); - const [ref, width] = useElementSize(); - const orientation = Math.ceil(width) < 640 ? 'vertical' : 'horizontal'; // 2 x 320 + 1rem - 2x border - - const playerNames: [string, string] = [ - matchResult.player0User?.givenName || matchResult.player0User?.username || matchResult.player0Placeholder || 'Unknown Player', - matchResult.player1User?.givenName || matchResult.player1User?.username || matchResult.player1Placeholder || 'Unknown Player', - ]; - - return ( -
-
-

Meta

-
- Ruleset: - {matchResult.gameSystemId} (More Info) -
-
- Points: - {matchResult.gameSystemConfig.points} -
-
- Era: - {matchResult.gameSystemConfig.eraId} -
-
-
-

Game Set-Up

- {`${playerNames[0]}\u{2019}s Battle Plan:`} - {fowV4BattlePlanOptions.find((option) => option.value === matchResult.details.player0BattlePlan)?.label} - {`${playerNames[1]}\u{2019}s Battle Plan:`} - {fowV4BattlePlanOptions.find((option) => option.value === matchResult.details.player1BattlePlan)?.label} - Mission: - {matchResult.details.missionName} - Attacker: - {playerNames[matchResult.details.attacker]} - First Turn: - {playerNames[matchResult.details.firstTurn]} -
-
-

Outcome

- Turns Played: - {matchResult.details.turnsPlayed} - {`${playerNames[0]}\u{2019}s Units Lost:`} - {matchResult.details.player0UnitsLost} - {`${playerNames[1]}\u{2019}s Units Lost:`} - {matchResult.details.player1UnitsLost} - Outcome: - {formatOutcome(matchResult.details, playerNames)} -
-
- ); -}; diff --git a/src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts b/src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts deleted file mode 100644 index 5b0a2f5c..00000000 --- a/src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MatchResult } from '~/api'; - -export const formatOutcome = (details: MatchResult['details'], playerNames: [string, string]): string => { - if (details.winner !== -1 && details.outcomeType === 'attack_repelled') { - return `${playerNames[details.winner]} repelled the attack`; - } - if (details.winner !== -1 && details.outcomeType === 'objective_taken') { - return `${playerNames[details.winner]} took the objective`; - } - if (details.winner !== -1 && details.outcomeType === 'force_broken') { - if (details.winner === 0) { - return `${playerNames[0]} broke ${playerNames[1]}\u{2019}s formation(s)`; - } - if (details.winner === 1) { - return `${playerNames[1]} broke ${playerNames[0]}\u{2019}s formation(s)`; - } - } - return 'Draw / Time Out'; -}; From 9b0a0573f5e95a6028b9a961ac75e5f067ccb911 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 15 Jul 2025 19:52:05 +0200 Subject: [PATCH 29/31] feat: Set page title based on title prop --- src/components/PageWrapper/PageWrapper.tsx | 13 +++++++++++-- .../TournamentDetailPage/TournamentDetailPage.tsx | 7 ++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/PageWrapper/PageWrapper.tsx b/src/components/PageWrapper/PageWrapper.tsx index 0c2d7291..35f8b0e7 100644 --- a/src/components/PageWrapper/PageWrapper.tsx +++ b/src/components/PageWrapper/PageWrapper.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import clsx from 'clsx'; import { ArrowLeft } from 'lucide-react'; @@ -17,6 +17,7 @@ export interface PageWrapperProps { maxWidth?: number; showBackButton?: boolean; title?: string; + hideTitle?: boolean; banner?: ReactNode; bannerBackgroundUrl?: string; } @@ -28,11 +29,19 @@ export const PageWrapper = ({ maxWidth = MAX_WIDTH, showBackButton = false, title, + hideTitle = false, banner, bannerBackgroundUrl, }: PageWrapperProps): JSX.Element => { const navigate = useNavigate(); const { pathname } = useLocation(); + + useEffect(() => { + if (title?.length) { + document.title = `Combat Command | ${title}`; + } + }, [title]); + const handleClickBack = (): void => { if (window.history.length > 1) { navigate(-1); @@ -60,7 +69,7 @@ export const PageWrapper = ({ )}
- {(showBackButton || title) && ( + {(showBackButton || (title && !hideTitle)) && (
{showBackButton && (