From 8f8f74e0dfb83d01c77aa56afe67f1fdf63656b1 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Sat, 26 Jul 2025 00:50:06 +0200 Subject: [PATCH 1/5] feat: Add tournament pairing detail page --- .../TournamentPairingsCard.tsx | 4 +- .../TournamentPairingsCard.utils.tsx | 21 +++++- .../TournamentPairingDetailPage.module.scss | 17 +++++ .../TournamentPairingDetailPage.tsx | 74 +++++++++++++++++++ .../TournamentPairingDetailPage/index.ts | 1 + src/routes.tsx | 6 ++ src/settings.ts | 1 + 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.module.scss create mode 100644 src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.tsx create mode 100644 src/pages/TournamentPairingDetailPage/index.ts diff --git a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx index 22eb0c02..1b0dfc35 100644 --- a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx @@ -1,4 +1,5 @@ import { ReactElement, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import clsx from 'clsx'; import { Zap } from 'lucide-react'; @@ -21,6 +22,7 @@ export interface TournamentPairingsCardProps { export const TournamentPairingsCard = ({ className, }: TournamentPairingsCardProps): JSX.Element => { + const navigate = useNavigate(); const { _id: tournamentId, lastRound, roundCount } = useTournament(); const actions = useTournamentActions(); @@ -34,7 +36,7 @@ export const TournamentPairingsCard = ({ round, }); - const columns = getTournamentPairingTableConfig(); + const columns = getTournamentPairingTableConfig(navigate); const rows = (tournamentPairings || []); const showEmptyState = !loading && !rows.length; diff --git a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.utils.tsx b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.utils.tsx index b7225739..700033e9 100644 --- a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.utils.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.utils.tsx @@ -1,16 +1,19 @@ -import { Swords } from 'lucide-react'; +import { generatePath, NavigateFunction } from 'react-router-dom'; +import { ChevronRight, Swords } from 'lucide-react'; import { TournamentPairing } from '~/api'; +import { Button } from '~/components/generic/Button'; import { CircularProgress } from '~/components/generic/CircularProgress'; import { InfoPopover } from '~/components/generic/InfoPopover'; import { ColumnDef } from '~/components/generic/Table'; import { TournamentPairingRow } from '~/components/TournamentPairingRow'; +import { PATHS } from '~/settings'; import styles from './TournamentPairingsCard.module.scss'; const matchIndicatorSize = 40; // 2.5rem -export const getTournamentPairingTableConfig = (): ColumnDef[] => [ +export const getTournamentPairingTableConfig = (navigate: NavigateFunction): ColumnDef[] => [ { key: 'table', label: 'Table', @@ -48,4 +51,18 @@ export const getTournamentPairingTableConfig = (): ColumnDef[ ), }, + { + key: 'viewPairing', + width: matchIndicatorSize, + align: 'center', + renderCell: ({ _id }) => ( + + ), + renderHeader: () => null, + }, ]; diff --git a/src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.module.scss b/src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.module.scss new file mode 100644 index 00000000..59299735 --- /dev/null +++ b/src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.module.scss @@ -0,0 +1,17 @@ +@use "/src/style/flex"; +@use "/src/style/text"; +@use "/src/style/variables"; +@use "/src/style/corners"; +@use "/src/style/variants"; +@use "/src/style/shadows"; +@use "/src/style/borders"; + +.TournamentPairingDetailPage { + @include variants.card; + + display: grid; + grid-template-areas: "players" "photos" "details"; + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + gap: 1rem; +} diff --git a/src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.tsx b/src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.tsx new file mode 100644 index 00000000..861d12ed --- /dev/null +++ b/src/pages/TournamentPairingDetailPage/TournamentPairingDetailPage.tsx @@ -0,0 +1,74 @@ +import { useParams } from 'react-router-dom'; + +import { TournamentPairingId } from '~/api'; +import { useAuth } from '~/components/AuthProvider'; +import { Button } from '~/components/generic/Button'; +import { Separator } from '~/components/generic/Separator'; +import { Spinner } from '~/components/generic/Spinner'; +import { MatchResultCard } from '~/components/MatchResultCard'; +import { MatchResultCreateDialog, useMatchResultCreateDialog } from '~/components/MatchResultCreateDialog'; +import { PageWrapper } from '~/components/PageWrapper'; +import { TournamentPairingRow } from '~/components/TournamentPairingRow'; +import { useGetMatchResultsByTournamentPairing } from '~/services/matchResults'; +import { useGetTournamentPairing } from '~/services/tournamentPairings'; +import { useGetTournament } from '~/services/tournaments'; +import { MAX_WIDTH } from '~/settings'; + +import styles from './TournamentPairingDetailPage.module.scss'; + +export const TournamentPairingDetailPage = (): JSX.Element => { + const user = useAuth(); + const params = useParams(); + const tournamentPairingId = params.id! as TournamentPairingId; // Must exist or else how did we get to this route? + + const { data: tournamentPairing, loading: tournamentPairingLoading } = useGetTournamentPairing({ id: tournamentPairingId }); + const { data: tournament, loading: tournamentLoading } = useGetTournament(tournamentPairing ? { + id: tournamentPairing.tournamentId, + } : 'skip'); + const { data: matchResults, loading: matchResultsLoading } = useGetMatchResultsByTournamentPairing({ + tournamentPairingId: tournamentPairingId, + }); + + const { open: openMatchResultCreateDialog } = useMatchResultCreateDialog(); + + const showLoading = !tournamentPairing || tournamentPairingLoading || matchResultsLoading || tournamentLoading; + + const isPlayer = user && tournamentPairing?.playerUserIds.includes(user._id); + const isOrganizer = user && tournament?.organizerUserIds.includes(user._id); + const isComplete = tournamentPairing && tournamentPairing.matchResultsProgress.submitted === tournamentPairing?.matchResultsProgress.required; + const showAddMatchResult = (isPlayer || isOrganizer) && !isComplete; + + return ( + +
+ {showLoading ? ( + + ) : ( + <> +
+ +
+
+ {`Submitted Match Results: ${tournamentPairing.matchResultsProgress.submitted}/${tournamentPairing.matchResultsProgress.required}`} +
+ +
+ {(matchResults ?? []).map((matchResult) => ( + + ))} +
+ {showAddMatchResult && ( + <> + +
+ +
+ + )} + + )} +
+ +
+ ); +}; diff --git a/src/pages/TournamentPairingDetailPage/index.ts b/src/pages/TournamentPairingDetailPage/index.ts new file mode 100644 index 00000000..a41f48d9 --- /dev/null +++ b/src/pages/TournamentPairingDetailPage/index.ts @@ -0,0 +1 @@ +export { TournamentPairingDetailPage } from './TournamentPairingDetailPage'; diff --git a/src/routes.tsx b/src/routes.tsx index 6783efd6..9d681eee 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -28,6 +28,7 @@ import { import { TournamentCreatePage } from '~/pages/TournamentCreatePage'; import { TournamentDetailPage } from '~/pages/TournamentDetailPage'; import { TournamentEditPage } from '~/pages/TournamentEditPage/TournamentEditPage'; +import { TournamentPairingDetailPage } from '~/pages/TournamentPairingDetailPage'; import { TournamentPairingsPage } from '~/pages/TournamentPairingsPage'; import { TournamentsPage } from '~/pages/TournamentsPage'; import { PATHS } from '~/settings'; @@ -137,6 +138,11 @@ export const routes = [ visibility: [], element: , }, + { + path: PATHS.tournamentPairingDetails, + visibility: [], + element: , + }, { path: PATHS.tournamentEdit, visibility: [], diff --git a/src/settings.ts b/src/settings.ts index 700a206d..728b76e6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -21,4 +21,5 @@ export const PATHS = { tournamentEdit: '/tournaments/:id/edit', tournamentPairings: '/tournaments/:id/pairings', tournaments: '/tournaments', + tournamentPairingDetails: '/pairings/:id', } as const; From 6286fa79761d5308e932d3dc6901c687549f6c84 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Sat, 26 Jul 2025 00:50:24 +0200 Subject: [PATCH 2/5] feat: Add score to match results --- convex/_model/fowV4/calculateFowV4MatchResultScore.ts | 3 +-- convex/_model/matchResults/_helpers/deepenMatchResult.ts | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/convex/_model/fowV4/calculateFowV4MatchResultScore.ts b/convex/_model/fowV4/calculateFowV4MatchResultScore.ts index 21d695ba..74c2517c 100644 --- a/convex/_model/fowV4/calculateFowV4MatchResultScore.ts +++ b/convex/_model/fowV4/calculateFowV4MatchResultScore.ts @@ -1,5 +1,4 @@ import { Doc } from '../../_generated/dataModel'; -import { DeepMatchResult } from '../matchResults'; /** * Calculate the Victory Points (i.e. score) for a given match result. @@ -10,7 +9,7 @@ import { DeepMatchResult } from '../matchResults'; * @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'> | DeepMatchResult): [number, number] => { +export const calculateFowV4MatchResultScore = (matchResult: Doc<'matchResults'>): [number, number] => { // TODO: Add some guards in case matchResult is not FowV4 diff --git a/convex/_model/matchResults/_helpers/deepenMatchResult.ts b/convex/_model/matchResults/_helpers/deepenMatchResult.ts index 55cd026f..b9d7625f 100644 --- a/convex/_model/matchResults/_helpers/deepenMatchResult.ts +++ b/convex/_model/matchResults/_helpers/deepenMatchResult.ts @@ -1,5 +1,6 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; +import { calculateFowV4MatchResultScore } from '../../fowV4/calculateFowV4MatchResultScore'; import { getMission } from '../../fowV4/getMission'; import { getUser } from '../../users/queries/getUser'; import { checkMatchResultBattlePlanVisibility } from './checkMatchResultBattlePlanVisibility'; @@ -38,6 +39,9 @@ export const deepenMatchResult = async ( const mission = getMission(matchResult.details.missionId); const battlePlansVisible = await checkMatchResultBattlePlanVisibility(ctx, matchResult); + // TODO: This is FowV4 specific, needs to be made generic! + const [player0Score, player1Score] = calculateFowV4MatchResultScore(matchResult); + return { ...matchResult, ...(player0User ? { player0User } : {}), @@ -47,6 +51,8 @@ export const deepenMatchResult = async ( player0BattlePlan: battlePlansVisible ? matchResult.details.player0BattlePlan : undefined, player1BattlePlan: battlePlansVisible ? matchResult.details.player1BattlePlan : undefined, missionName: mission?.displayName, + player0Score, + player1Score, }, likedByUserIds: likes.map((like) => like.userId), commentCount: comments.length, From ce00cf4744b4dd14cd4e1d03c450f16cf118e969 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Sat, 26 Jul 2025 00:51:29 +0200 Subject: [PATCH 3/5] feat: Improve match result editing and creation for tournaments --- .../_helpers/deepenTournamentPairing.ts | 2 +- .../FowV4MatchResultDetails.types.ts | 2 +- .../FowV4MatchResultForm.tsx | 23 +++++++++---------- .../components/CommonFields.hooks.ts | 8 ++----- .../components/TournamentPlayersFields.tsx | 12 +++++----- src/components/IdentityBadge/index.ts | 5 +++- .../MatchResultContextMenu.tsx | 9 +++++++- .../MatchResultCreateDialog.hooks.tsx | 3 ++- .../MatchResultCreateDialog.tsx | 12 ++++++++-- .../MatchResultEditDialog.tsx | 2 +- 10 files changed, 46 insertions(+), 32 deletions(-) diff --git a/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts b/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts index 1ed4b351..00650d54 100644 --- a/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts +++ b/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts @@ -14,7 +14,7 @@ import { getTournamentShallow } from '../../tournaments'; * This method's return type is, by nature, the definition of a deep TournamentPairing. * * @param ctx - Convex query context - * @param tournament - Raw TournamentPairing document + * @param tournamentPairing - Raw TournamentPairing document * @returns A deep TournamentPairing */ export const deepenTournamentPairing = async ( diff --git a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.types.ts b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.types.ts index 15c3b11c..4ecf00f2 100644 --- a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.types.ts +++ b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.types.ts @@ -3,7 +3,7 @@ import { MatchResult, User } from '~/api'; export type FowV4MatchResultDetailsData = Pick & { player0User?: User; player1User?: User; - details: Omit & { + details: Omit & { missionName?: string; } }; diff --git a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx index 4c872870..1979efef 100644 --- a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx +++ b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import clsx from 'clsx'; import { + MatchResult, MatchResultId, TournamentPairingId, UserId, @@ -40,7 +41,7 @@ const confirmMatchResultDialogId = 'confirm-match-result'; export interface FowV4MatchResultFormProps { id: string; className?: string; - matchResultId?: MatchResultId; + matchResult?: MatchResult; tournamentPairingId?: TournamentPairingId; onSuccess?: () => void; } @@ -48,17 +49,12 @@ export interface FowV4MatchResultFormProps { export const FowV4MatchResultForm = ({ id, className, - matchResultId, + matchResult, tournamentPairingId: forcedTournamentPairingId, onSuccess, }: FowV4MatchResultFormProps): JSX.Element => { const user = useAuth(); - const { - data: matchResult, - loading: matchResultLoading, - } = useGetMatchResult(matchResultId ? { id: matchResultId } : 'skip'); - const [ tournamentPairingId, setTournamentPairingId, @@ -89,9 +85,10 @@ export const FowV4MatchResultForm = ({ const form = useForm({ resolver: zodResolver(fowV4MatchResultFormSchema), - defaultValues, - // React-Hook-Form is stupid and doesn't allow applying a partial record to the form values - values: { ...matchResult as FowV4MatchResultFormData }, + defaultValues: { + ...defaultValues, + ...(matchResult ? fowV4MatchResultFormSchema.parse(matchResult) : {}), + }, mode: 'onSubmit', }); @@ -143,13 +140,15 @@ export const FowV4MatchResultForm = ({ const disableSubmit = createMatchResultLoading || updateMatchResultLoading; - if (tournamentPairingsLoading || matchResultLoading) { + if (tournamentPairingsLoading) { return
Loading...
; } + console.log(form.watch()); + return (
- {!matchResultId && ( + {!matchResult && !forcedTournamentPairingId && ( <>