Tournament team page (#1380)

* WIP

* Query

* Backend for team page done

* UI function made

* UI work

* Tweaks

* Fix showing players from own team

* Optimize performance

* Inactive players styling

* Stage popover
This commit is contained in:
Kalle 2023-05-28 15:25:46 +03:00 committed by GitHub
parent 1adb57003c
commit 7342dd1667
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1000 additions and 104 deletions

2
.nvmrc
View File

@ -1 +1 @@
v18.12.1
v18.16.0

View File

@ -10,6 +10,7 @@ export type PlacementProps = {
iconClassName?: string;
textClassName?: string;
size?: number;
textOnly?: boolean;
};
const getSpecialPlacementIconPath = (placement: number): string | null => {
@ -30,6 +31,7 @@ export function Placement({
iconClassName,
textClassName,
size = 20,
textOnly = false,
}: PlacementProps) {
const { t } = useTranslation(undefined, {});
@ -45,7 +47,7 @@ export function Placement({
const isSuperscript = ordinalSuffix.startsWith("^");
const ordinalSuffixText = ordinalSuffix.replace(/^\^/, "");
const iconPath = getSpecialPlacementIconPath(placement);
const iconPath = textOnly ? null : getSpecialPlacementIconPath(placement);
if (!iconPath) {
return (

View File

@ -1,4 +1,5 @@
import { Popover as HeadlessPopover } from "@headlessui/react";
import type { Placement } from "@popperjs/core";
import * as React from "react";
import { usePopper } from "react-popper";
@ -8,15 +9,27 @@ export function Popover({
buttonChildren,
triggerClassName,
containerClassName,
placement,
}: {
children: React.ReactNode;
buttonChildren: React.ReactNode;
triggerClassName?: string;
containerClassName?: string;
placement?: Placement;
}) {
const [referenceElement, setReferenceElement] = React.useState();
const [popperElement, setPopperElement] = React.useState();
const { styles, attributes } = usePopper(referenceElement, popperElement);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
],
});
return (
<HeadlessPopover className={containerClassName}>

View File

@ -314,7 +314,6 @@ function ActionSectionWrapper({
children: React.ReactNode;
icon?: "warning" | "info" | "success" | "error";
"justify-center"?: boolean;
"data-cy"?: string;
}) {
// todo: flex-dir: column on mobile
const style = icon
@ -323,11 +322,7 @@ function ActionSectionWrapper({
}
: undefined;
return (
<section
className="tournament__action-section"
style={style as any}
data-cy={rest["data-cy"]}
>
<section className="tournament__action-section" style={style as any}>
<div
className={clsx("tournament__action-section__content", {
"justify-center": rest["justify-center"],

View File

@ -9,9 +9,10 @@ import type {
} from "../../tournament/routes/to.$id";
import type { Unpacked } from "~/utils/types";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import { useLoaderData } from "@remix-run/react";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
import type { Result } from "./ScoreReporter";
import { tournamentTeamPage } from "~/utils/urls";
export type TeamRosterInputsType = "DEFAULT" | "DISABLED" | "PRESENTATIONAL";
@ -33,6 +34,7 @@ export function TeamRosterInputs({
}) {
const presentational = Boolean(result);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<TournamentMatchLoaderData>();
const inputMode = (
team: Unpacked<TournamentLoaderData["teams"]>
@ -71,7 +73,15 @@ export function TeamRosterInputs({
<span className="tournament-bracket__during-match-actions__seed">
#{data.seeds[teamI]}
</span>{" "}
{team.name}
<Link
to={tournamentTeamPage({
eventId: parentRouteData.event.id,
tournamentTeamId: team.id,
})}
className="tournament-bracket__during-match-actions__team-name"
>
{team.name}
</Link>
</h4>
<WinnerRadio
presentational={presentational}

View File

@ -2,7 +2,7 @@ import type { Tournament, TournamentTeam } from "~/db/types";
import type { BracketsManager } from "~/modules/brackets-manager";
import type { FinalStandingsItem } from "~/modules/brackets-manager/types";
import type { PlayerThatPlayedByTeamId } from "../queries/playersThatPlayedByTeamId.server";
import { playersThatPlayedByTeamId } from "../queries/playersThatPlayedByTeamId.server";
import { playersThatPlayedByTournamentId } from "../queries/playersThatPlayedByTeamId.server";
export interface FinalStanding {
tournamentTeam: Pick<TournamentTeam, "id" | "name">;
@ -15,9 +15,11 @@ const STANDINGS_TO_INCLUDE = 8;
export function finalStandings({
manager,
tournamentId,
includeAll,
}: {
manager: BracketsManager;
tournamentId: Tournament["id"];
includeAll?: boolean;
}): Array<FinalStanding> | null {
let standings: FinalStandingsItem[];
try {
@ -32,14 +34,17 @@ export function finalStandings({
throw e;
}
if (!includeAll) {
standings = standings.slice(0, STANDINGS_TO_INCLUDE);
}
const playersThatPlayed = playersThatPlayedByTournamentId(tournamentId);
const result: Array<FinalStanding> = [];
let lastRank = 1;
let currentPlacement = 1;
for (const [i, standing] of standings
.slice(0, STANDINGS_TO_INCLUDE)
.entries()) {
for (const [i, standing] of standings.entries()) {
if (lastRank !== standing.rank) {
lastRank = standing.rank;
currentPlacement = i + 1;
@ -50,9 +55,30 @@ export function finalStandings({
name: standing.name,
},
placement: currentPlacement,
players: playersThatPlayedByTeamId(standing.id),
players: playersThatPlayed.filter(
(p) => p.tournamentTeamId === standing.id
),
});
}
return result;
}
export function finalStandingOfTeam({
manager,
tournamentId,
tournamentTeamId,
}: {
manager: BracketsManager;
tournamentId: Tournament["id"];
tournamentTeamId: TournamentTeam["id"];
}) {
const standings = finalStandings({ manager, tournamentId, includeAll: true });
if (!standings) return null;
return (
standings.find(
(standing) => standing.tournamentTeam.id === tournamentTeamId
) ?? null
);
}

View File

@ -1 +1,6 @@
export { HACKY_resolvePoolCode } from "./tournament-bracket-utils";
export {
HACKY_resolvePoolCode,
everyMatchIsOver,
} from "./tournament-bracket-utils";
export { getTournamentManager } from "./core/brackets-manager";
export { finalStandingOfTeam } from "./core/finalStandings.server";

View File

@ -8,7 +8,8 @@ const stm = sql.prepare(/* sql */ `
"User"."discordAvatar",
"User"."discordId",
"User"."customUrl",
"User"."country"
"User"."country",
"TournamentTeam"."id" as "tournamentTeamId"
from "TournamentTeam"
left join "TournamentTeamMember" on "TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id"
left join "User" on "User"."id" = "TournamentTeamMember"."userId"
@ -20,17 +21,17 @@ const stm = sql.prepare(/* sql */ `
and
"TournamentTeamMember"."userId" = "TournamentMatchGameResultParticipant"."userId"
where "TournamentTeam"."id" = @tournamentTeamId
where "TournamentTeam"."tournamentId" = @tournamentId
group by "User"."id"
`);
export type PlayerThatPlayedByTeamId = Pick<
User,
"id" | "discordName" | "discordAvatar" | "discordId" | "customUrl" | "country"
>;
> & { tournamentTeamId: number };
export function playersThatPlayedByTeamId(
tournamentTeamId: number
export function playersThatPlayedByTournamentId(
tournamentId: number
): PlayerThatPlayedByTeamId[] {
return stm.all({ tournamentTeamId });
return stm.all({ tournamentId });
}

View File

@ -25,6 +25,7 @@ import { notFoundIfFalsy, validate } from "~/utils/remix";
import {
tournamentBracketsSubscribePage,
tournamentMatchPage,
tournamentTeamPage,
userPage,
} from "~/utils/urls";
import type { TournamentLoaderData } from "../../tournament/routes/to.$id";
@ -464,6 +465,7 @@ function TournamentProgressPrompt({ ownedTeamId }: { ownedTeamId: number }) {
}
function FinalStandings({ standings }: { standings: FinalStanding[] }) {
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [viewAll, setViewAll] = React.useState(false);
if (standings.length < 3) {
@ -489,9 +491,15 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
<div>
<Placement placement={standing.placement} size={40} />
</div>
<div className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big">
<Link
to={tournamentTeamPage({
eventId: parentRouteData.event.id,
tournamentTeamId: standing.tournamentTeam.id,
})}
className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big"
>
{standing.tournamentTeam.name}
</div>
</Link>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
return (
@ -540,9 +548,15 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
className="tournament-bracket__standing"
key={standing.tournamentTeam.id}
>
<div className="tournament-bracket__standing__team-name">
<Link
to={tournamentTeamPage({
eventId: parentRouteData.event.id,
tournamentTeamId: standing.tournamentTeam.id,
})}
className="tournament-bracket__standing__team-name"
>
{standing.tournamentTeam.name}
</div>
</Link>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
return (

View File

@ -146,6 +146,9 @@ export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
}
export function everyMatchIsOver(bracket: ValueToArray<DataTypes>) {
// tournament didn't start yet
if (bracket.match.length === 0) return false;
let lastWinner = -1;
for (const [i, match] of bracket.match.entries()) {
// special case - bracket reset might not be played depending on who wins in the grands

View File

@ -195,6 +195,11 @@
color: var(--theme);
}
.tournament-bracket__during-match-actions__team-name {
color: var(--text);
font-weight: var(--bold);
}
.tournament-bracket__mode-progress {
display: flex;
margin-bottom: var(--s-4);
@ -294,6 +299,7 @@
font-weight: var(--semi-bold);
text-align: center;
font-size: var(-fonts-sm);
color: var(--text);
}
.tournament-bracket__standing__team-name__big {

View File

@ -0,0 +1,93 @@
import { Link } from "@remix-run/react";
import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server";
import { Avatar } from "~/components/Avatar";
import { userPage } from "~/utils/urls";
import { ModeImage, StageImage } from "~/components/Image";
import clsx from "clsx";
import type { User } from "~/db/types";
export function TeamWithRoster({
team,
seed,
teamPageUrl,
activePlayers,
}: {
team: Pick<FindTeamsByTournamentIdItem, "members" | "name" | "mapPool">;
seed?: number;
teamPageUrl?: string;
activePlayers?: User["id"][];
}) {
return (
<div>
<div className="tournament__team-with-roster">
<div className="tournament__team-with-roster__name">
{seed ? (
<span className="tournament__team-with-roster__seed">#{seed}</span>
) : null}{" "}
{teamPageUrl ? <Link to={teamPageUrl}>{team.name}</Link> : team.name}
</div>
<ul className="tournament__team-with-roster__members">
{team.members.map((member) => (
<li
key={member.userId}
className={clsx("tournament__team-with-roster__member", {
"tournament__team-with-roster__member__inactive":
activePlayers && !activePlayers.includes(member.userId),
})}
>
<Avatar
user={member}
size="xxs"
className={
activePlayers && !activePlayers.includes(member.userId)
? "tournament__team-with-roster__member__avatar-inactive"
: undefined
}
/>
<Link
to={userPage(member)}
className="tournament__team-member-name"
>
{member.discordName}{" "}
{member.isOwner ? (
<span className="tournament__team-member-name__captain">
(C)
</span>
) : null}
</Link>
</li>
))}
</ul>
</div>
{team.mapPool && team.mapPool.length > 0 ? (
<TeamMapPool mapPool={team.mapPool} />
) : null}
</div>
);
}
function TeamMapPool({
mapPool,
}: {
mapPool: NonNullable<FindTeamsByTournamentIdItem["mapPool"]>;
}) {
return (
<div
className={clsx("tournament__team-with-roster__map-pool", {
"tournament__team-with-roster__map-pool__3-columns":
mapPool.length % 3 === 0,
})}
>
{mapPool.map(({ mode, stageId }, i) => {
return (
<div key={i}>
<StageImage stageId={stageId} width={85} />
<div className="tournament__team-with-roster__map-pool__mode-info">
<ModeImage mode={mode} size={16} />
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,176 @@
import type { User } from "~/db/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import {
type SetHistoryByTeamIdItem,
setHistoryByTeamId,
} from "../queries/setHistoryByTeamId.server";
import { findRoundNumbersByTournamentId } from "../queries/findRoundNumbersByTournamentId.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import { sourceTypes } from "~/modules/tournament-map-list-generator";
import invariant from "tiny-invariant";
export interface PlayedSet {
tournamentMatchId: number;
score: [teamBeingViewed: number, opponent: number];
round: {
type: "winners" | "losers" | "single_elim";
round: number | "finals" | "grand_finals" | "bracket_reset";
};
bracket: "main" | "underground";
maps: Array<{
stageId: StageId;
modeShort: ModeShort;
result: "win" | "loss";
source: TournamentMaplistSource;
}>;
opponent: {
id: number;
name: string;
/** Team's roster that played in this set */
roster: Array<
Pick<
User,
"id" | "discordName" | "discordAvatar" | "discordId" | "customUrl"
>
>;
};
}
export function winCounts(sets: PlayedSet[]) {
let setsWon = 0;
let totalSets = 0;
let mapsWon = 0;
let totalMaps = 0;
for (const set of sets) {
let mapsWonThisSet = 0;
let totalMapsThisSet = 0;
for (const map of set.maps) {
if (map.result === "win") {
mapsWonThisSet++;
}
totalMapsThisSet++;
}
totalSets++;
if (mapsWonThisSet > totalMapsThisSet / 2) {
setsWon++;
}
mapsWon += mapsWonThisSet;
totalMaps += totalMapsThisSet;
}
return {
sets: {
won: setsWon,
total: totalSets,
percentage: Math.round((setsWon / totalSets) * 100),
},
maps: {
won: mapsWon,
total: totalMaps,
percentage: Math.round((mapsWon / totalMaps) * 100),
},
};
}
export function tournamentTeamSets({
tournamentTeamId,
tournamentId,
}: {
tournamentTeamId: number;
tournamentId: number;
}): PlayedSet[] {
const sets = setHistoryByTeamId(tournamentTeamId);
const allRoundNumbers = findRoundNumbersByTournamentId(tournamentId);
return sets.map((set) => {
const resolveRound = () => {
if (set.groupNumber === 3) {
if (set.roundNumber === 2) return "bracket_reset";
return "grand_finals";
}
// TODO: also consider stageId
const maxRoundNumberOfGroup = Math.max(
...allRoundNumbers
.filter((round) => round.groupNumber === set.groupNumber)
.map((round) => round.roundNumber)
);
if (set.roundNumber === maxRoundNumberOfGroup) return "finals";
return set.roundNumber;
};
return {
tournamentMatchId: set.tournamentMatchId,
bracket: "main",
round: {
round: resolveRound(),
type: resolveRoundType({ groupNumber: set.groupNumber }),
},
maps: set.matches.map((match) => ({
stageId: match.stageId,
modeShort: match.mode,
result: match.wasWinner ? "win" : "loss",
source: parseTournamentMaplistSource(match.source),
})),
score: flipScoreIfNeeded(set),
opponent: {
id: set.otherTeamId,
name: set.otherTeamName,
roster: set.players,
},
};
});
}
function parseTournamentMaplistSource(source: string): TournamentMaplistSource {
if (sourceTypes.includes(source as any)) {
return source as TournamentMaplistSource;
}
const parsed = Number(source);
invariant(!Number.isNaN(parsed), `Invalid source: ${source}`);
return parsed;
}
function flipScoreIfNeeded(set: SetHistoryByTeamIdItem): [number, number] {
const score: [number, number] = [
set.opponentOneScore ?? 0,
set.opponentTwoScore ?? 0,
];
const wonTheSet =
set.matches.reduce((acc, cur) => cur.wasWinner + acc, 0) >
set.matches.length / 2;
if (
(wonTheSet && score[0] < score[1]) ||
(!wonTheSet && score[0] > score[1])
) {
return [score[1], score[0]];
}
return score;
}
// TODO: this only works for DE
function resolveRoundType({ groupNumber }: { groupNumber: number }) {
if (groupNumber === 1 || groupNumber === 3) {
return "winners";
}
if (groupNumber === 2) {
return "losers";
}
// TODO: resolve this correctly
return "single_elim";
}

View File

@ -2,6 +2,13 @@ import { getStreams } from "~/modules/twitch";
import { participantTwitchUsersByTournamentId } from "../queries/participantTwitchUsersByTournamentId.server";
export async function streamsByTournamentId(tournamentId: number) {
// prevent error logs in development
if (
process.env.NODE_ENV === "development" &&
!process.env["TWITCH_CLIENT_ID"]
) {
return [];
}
const twitchUsersOfTournament =
participantTwitchUsersByTournamentId(tournamentId);

View File

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
select
"TournamentStage"."id" as "stageId",
"TournamentRound"."number" as "roundNumber",
"TournamentGroup"."number" as "groupNumber"
from "TournamentStage"
left join "TournamentGroup" on "TournamentGroup"."stageId" = "TournamentStage"."id"
left join "TournamentRound" on "TournamentRound"."groupId" = "TournamentGroup"."id"
where "TournamentStage"."tournamentId" = @tournamentId
group by "TournamentStage"."id", "TournamentRound"."number", "TournamentGroup"."number"
`);
export function findRoundNumbersByTournamentId(tournamentId: number): Array<{
stageId: number;
roundNumber: number;
groupNumber: number;
}> {
return stm.all({ tournamentId });
}

View File

@ -0,0 +1,118 @@
import { sql } from "~/db/sql";
import type { TournamentMatchGameResult, User } from "~/db/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { removeDuplicatesByProperty } from "~/utils/arrays";
import { parseDBArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
with "q1" as (
select
"m"."id" as "tournamentMatchId",
"m"."opponentOne" ->> '$.score' as "opponentOneScore",
"m"."opponentTwo" ->> '$.score' as "opponentTwoScore",
"otherTeam"."name" as "otherTeamName",
"otherTeam"."id" as "otherTeamId",
"round"."number" as "roundNumber",
"group"."number" as "groupNumber",
json_group_array(
json_object(
'mode',
"r"."mode",
'stageId',
"r"."stageId",
'wasWinner',
"r"."winnerTeamId" == @tournamentTeamId,
'source',
"r"."source"
)
) as "matches"
from "TournamentMatch" as "m"
left join "TournamentMatchGameResult" as "r" on "m"."id" = "r"."matchId"
left join "TournamentRound" as "round" on "m"."roundId" = "round"."id"
left join "TournamentGroup" as "group" on "m"."groupId" = "group"."id"
left join "TournamentTeam" as "otherTeam" on
(
"m"."opponentOne" ->> '$.id' != @tournamentTeamId
and
"m"."opponentOne" ->> '$.id' = "otherTeam"."id"
) or
(
"m"."opponentTwo" ->> '$.id' != @tournamentTeamId
and
"m"."opponentTwo" ->> '$.id' = "otherTeam"."id"
)
where
(
"m"."opponentOne" ->> '$.id' = @tournamentTeamId
or
"m"."opponentTwo" ->> '$.id' = @tournamentTeamId
)
and "m"."status" >= 4
group by "m"."id"
order by "groupNumber" asc, "roundNumber" asc, "r"."number" asc
)
select
"q1".*,
json_group_array(
json_object(
'id',
"u"."id",
'discordName',
"u"."discordName",
'discordAvatar',
"u"."discordAvatar",
'discordId',
"u"."discordId",
'customUrl',
"u"."customUrl"
)
) as "players"
from "q1"
left join "TournamentMatchGameResult" as "r" on "q1"."tournamentMatchId" = "r"."matchId"
left join "TournamentMatchGameResultParticipant" as "p" on "r"."id" = "p"."matchGameResultId"
left join "User" as "u" on "p"."userId" = "u"."id"
-- filters out own team results
inner join "TournamentTeamMember" as "m" on "p"."userId" = "m"."userId"
and "m"."tournamentTeamId" == "q1"."otherTeamId"
group by "q1"."tournamentMatchId"
`);
export interface SetHistoryByTeamIdItem {
tournamentMatchId: number;
opponentOneScore: number | null;
opponentTwoScore: number | null;
otherTeamName: string;
otherTeamId: number;
roundNumber: number;
groupNumber: number;
matches: {
stageId: StageId;
source: TournamentMatchGameResult["source"];
mode: ModeShort;
wasWinner: number;
}[];
players: Array<
Pick<
User,
"id" | "discordName" | "discordAvatar" | "discordId" | "customUrl"
>
>;
}
export function setHistoryByTeamId(
tournamentTeamId: number
): Array<SetHistoryByTeamIdItem> {
const rows = stm.all({ tournamentTeamId });
return rows.map((row) => {
return {
...row,
matches: parseDBArray(row.matches),
// TODO: there is probably a way to do this in SQL
players: removeDuplicatesByProperty(
parseDBArray(row.players),
(u: Pick<User, "id">) => u.id
),
};
});
}

View File

@ -0,0 +1,243 @@
import type { LoaderArgs } from "@remix-run/node";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import { Placement } from "~/components/Placement";
import {
everyMatchIsOver,
finalStandingOfTeam,
getTournamentManager,
} from "~/features/tournament-bracket";
import { TeamWithRoster } from "../components/TeamWithRoster";
import {
type PlayedSet,
tournamentTeamSets,
winCounts,
} from "../core/sets.server";
import {
tournamentIdFromParams,
tournamentRoundI18nKey,
tournamentTeamIdFromParams,
} from "../tournament-utils";
import type { TournamentLoaderData } from "./to.$id";
import { ModeImage, StageImage } from "~/components/Image";
import clsx from "clsx";
import { Avatar } from "~/components/Avatar";
import {
tournamentMatchPage,
tournamentPage,
tournamentTeamPage,
userPage,
} from "~/utils/urls";
import { useTranslation } from "~/hooks/useTranslation";
import { Redirect } from "~/components/Redirect";
import { Popover } from "~/components/Popover";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server";
export const loader = ({ params }: LoaderArgs) => {
const tournamentId = tournamentIdFromParams(params);
const tournamentTeamId = tournamentTeamIdFromParams(params);
const manager = getTournamentManager("SQL");
const bracket = manager.get.tournamentData(tournamentId);
const _everyMatchIsOver = everyMatchIsOver(bracket);
const standing = _everyMatchIsOver
? finalStandingOfTeam({ manager, tournamentId, tournamentTeamId })
: null;
const sets = tournamentTeamSets({ tournamentTeamId, tournamentId });
return {
tournamentTeamId,
placement: standing?.placement,
sets,
winCounts: winCounts(sets),
playersThatPlayed: standing?.players.map((p) => p.id),
};
};
// TODO: could cache this after tournament is finalized
export default function TournamentTeamPage() {
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const teamIndex = parentRouteData.teams.findIndex(
(t) => t.id === data.tournamentTeamId
);
const team = parentRouteData.teams[teamIndex];
if (!team) {
return <Redirect to={tournamentPage(parentRouteData.event.id)} />;
}
return (
<div className="stack lg">
<TeamWithRoster team={team} activePlayers={data.playersThatPlayed} />
{data.winCounts.sets.total > 0 ? (
<StatSquares
seed={teamIndex + 1}
teamsCount={parentRouteData.teams.length}
/>
) : null}
<div className="tournament__team__sets">
{data.sets.map((set) => {
return <SetInfo key={set.tournamentMatchId} set={set} team={team} />;
})}
</div>
</div>
);
}
function StatSquares({
seed,
teamsCount,
}: {
seed: number;
teamsCount: number;
}) {
const data = useLoaderData<typeof loader>();
return (
<div className="tournament__team__stats">
<div className="tournament__team__stat">
<div className="tournament__team__stat__title">Set wins</div>
<div className="tournament__team__stat__main">
{data.winCounts.sets.won} / {data.winCounts.sets.total}
</div>
<div className="tournament__team__stat__sub">
{data.winCounts.sets.percentage}%
</div>
</div>
<div className="tournament__team__stat">
<div className="tournament__team__stat__title">Map wins</div>
<div className="tournament__team__stat__main">
{data.winCounts.maps.won} / {data.winCounts.maps.total}
</div>
<div className="tournament__team__stat__sub">
{data.winCounts.maps.percentage}%
</div>
</div>
<div className="tournament__team__stat">
<div className="tournament__team__stat__title">Seed</div>
<div className="tournament__team__stat__main">{seed}</div>
<div className="tournament__team__stat__sub">out of {teamsCount}</div>
</div>
<div className="tournament__team__stat">
<div className="tournament__team__stat__title">Placement</div>
<div className="tournament__team__stat__main">
{data.placement ? (
<Placement placement={data.placement} textOnly />
) : (
"-"
)}
</div>
</div>
</div>
);
}
function SetInfo({
set,
team,
}: {
set: PlayedSet;
team: FindTeamsByTournamentIdItem;
}) {
const { t } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const sourceToText = (source: TournamentMaplistSource) => {
switch (source) {
case "BOTH":
return t("tournament:pickInfo.both");
case "DEFAULT":
return t("tournament:pickInfo.default");
case "TIEBREAKER":
return t("tournament:pickInfo.tiebreaker");
default: {
const teamName =
source === set.opponent.id ? set.opponent.name : team.name;
return t("tournament:pickInfo.team.specific", { team: teamName });
}
}
};
return (
<div className="tournament__team__set">
<div className="tournament__team__set__top-container">
<div className="tournament__team__set__score">
{set.score.join("-")}
</div>
<Link
to={tournamentMatchPage({
matchId: set.tournamentMatchId,
eventId: parentRouteData.event.id,
})}
className="tournament__team__set__round-name"
>
{t(`tournament:${tournamentRoundI18nKey(set.round)}`, {
round: set.round.round,
})}{" "}
- {t(`tournament:bracket.${set.bracket}`)}
</Link>
</div>
<div className="overlap-divider">
<div className="stack horizontal sm">
{set.maps.map(({ stageId, modeShort, result, source }, i) => {
return (
<Popover
key={i}
buttonChildren={
<ModeImage
mode={modeShort}
size={20}
containerClassName={clsx("tournament__team__set__mode", {
tournament__team__set__mode__loss: result === "loss",
})}
/>
}
placement="top"
>
<div className="tournament__team__set__stage-container">
<StageImage
stageId={stageId}
width={125}
className="rounded-sm"
/>
{sourceToText(source)}
</div>
</Popover>
);
})}
</div>
</div>
<div className="tournament__team__set__opponent">
<div className="tournament__team__set__opponent__vs">vs.</div>
<Link
to={tournamentTeamPage({
tournamentTeamId: set.opponent.id,
eventId: parentRouteData.event.id,
})}
className="tournament__team__set__opponent__team"
>
{set.opponent.name}
</Link>
<div className="tournament__team__set__opponent__members">
{set.opponent.roster.map((user) => {
return (
<Link
to={userPage(user)}
key={user.id}
className="tournament__team__set__opponent__member"
>
<Avatar user={user} size="xxs" />
{user.discordName}
</Link>
);
})}
</div>
</div>
</div>
);
}

View File

@ -1,10 +1,7 @@
import { Link, useOutletContext } from "@remix-run/react";
import { Avatar } from "~/components/Avatar";
import { ModeImage, StageImage } from "~/components/Image";
import { userPage } from "~/utils/urls";
import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server";
import { useOutletContext } from "@remix-run/react";
import { tournamentTeamPage } from "~/utils/urls";
import { TeamWithRoster } from "../components/TeamWithRoster";
import type { TournamentLoaderData } from "./to.$id";
import clsx from "clsx";
export default function TournamentTeamsPage() {
const data = useOutletContext<TournamentLoaderData>();
@ -12,75 +9,16 @@ export default function TournamentTeamsPage() {
return (
<div className="stack lg">
{data.teams.map((team, i) => {
return <TeamWithRoster key={team.id} team={team} seed={i + 1} />;
})}
</div>
);
}
function TeamWithRoster({
team,
seed,
}: {
team: Pick<FindTeamsByTournamentIdItem, "members" | "name" | "mapPool">;
seed: number;
}) {
return (
<div>
<div className="tournament__team-with-roster">
<div className="tournament__team-with-roster__name">
<span className="tournament__team-with-roster__seed">#{seed}</span>{" "}
{team.name}
</div>
<ul className="tournament__team-with-roster__members">
{team.members.map((member) => (
<li
key={member.userId}
className="tournament__team-with-roster__member"
>
<Avatar user={member} size="xxs" />
<Link
to={userPage(member)}
className="tournament__team-member-name"
>
{member.discordName}{" "}
{member.isOwner ? (
<span className="tournament__team-member-name__captain">
(C)
</span>
) : null}
</Link>
</li>
))}
</ul>
</div>
{team.mapPool && team.mapPool.length > 0 ? (
<TeamMapPool mapPool={team.mapPool} />
) : null}
</div>
);
}
function TeamMapPool({
mapPool,
}: {
mapPool: NonNullable<FindTeamsByTournamentIdItem["mapPool"]>;
}) {
return (
<div
className={clsx("tournament__team-with-roster__map-pool", {
"tournament__team-with-roster__map-pool__3-columns":
mapPool.length % 3 === 0,
})}
>
{mapPool.map(({ mode, stageId }, i) => {
return (
<div key={i}>
<StageImage stageId={stageId} width={85} />
<div className="tournament__team-with-roster__map-pool__mode-info">
<ModeImage mode={mode} size={16} />
</div>
</div>
<TeamWithRoster
key={team.id}
team={team}
seed={i + 1}
teamPageUrl={tournamentTeamPage({
eventId: data.event.id,
tournamentTeamId: team.id,
})}
/>
);
})}
</div>

View File

@ -11,6 +11,7 @@ import type {
} from "./routes/to.$id";
import { TOURNAMENT } from "./tournament-constants";
import { validate } from "~/utils/remix";
import type { PlayedSet } from "./core/sets.server";
export function resolveOwnedTeam({
teams,
@ -39,6 +40,13 @@ export function tournamentIdFromParams(params: Params<string>) {
return result;
}
export function tournamentTeamIdFromParams(params: Params<string>) {
const result = Number(params["tid"]);
invariant(!Number.isNaN(result), "tid is not a number");
return result;
}
export function modesIncluded(
tournament: Pick<Tournament, "mapPickingStyle">
): ModeShort[] {
@ -121,3 +129,13 @@ export function validateCanCheckIn({
return true;
}
export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
if (round.round === "grand_finals") return `bracket.grand_finals` as const;
if (round.round === "bracket_reset") {
return `bracket.grand_finals.bracket_reset` as const;
}
if (round.round === "finals") return `bracket.${round.type}.finals` as const;
return `bracket.${round.type}` as const;
}

View File

@ -128,6 +128,16 @@
grid-template-columns: max-content max-content 1fr;
}
.tournament__team-with-roster__member__inactive {
text-decoration: line-through;
color: var(--text-lighter);
text-decoration-thickness: 2px;
}
.tournament__team-with-roster__member__avatar-inactive {
opacity: 0.4;
}
.tournament__team-with-roster__map-pool {
display: grid;
grid-template-columns: max-content max-content max-content max-content;
@ -156,7 +166,6 @@
.tournament__team-with-roster__seed {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--theme);
}
.tournament__team-member-name {
@ -370,9 +379,165 @@
width: 0.75rem;
}
.tournament__team__stats {
border-radius: var(--rounded);
padding: var(--s-4);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-4);
margin: 0 auto;
}
.tournament__team__stat {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 2fr 1fr;
place-items: center;
font-weight: var(--bold);
text-align: center;
}
.tournament__team__stat__main {
font-size: var(--fonts-xl);
font-weight: var(--semi-bold);
}
.tournament__team__stat__main sup {
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
}
.tournament__team__stat__sub {
color: var(--text-lighter);
font-size: var(--fonts-sm);
}
@media screen and (min-width: 640px) {
.tournament__section {
margin: 0;
border-radius: var(--rounded);
}
.tournament__team__stats {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
}
.tournament__team__sets {
display: flex;
flex-direction: column;
max-width: 32rem;
margin: 0 auto;
gap: var(--s-8);
}
.tournament__team__set {
display: flex;
flex-direction: column;
gap: var(--s-2-5);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
padding: var(--s-3) var(--s-6);
}
.tournament__team__set__top-container {
display: flex;
justify-content: center;
gap: var(--s-2);
flex-direction: column;
align-items: center;
text-align: center;
}
@media screen and (min-width: 480px) {
.tournament__team__set__top-container {
flex-direction: row;
align-items: flex-end;
}
}
.tournament__team__set__score {
font-size: var(--fonts-xl);
font-weight: var(--bold);
}
.tournament__team__set__round-name {
font-size: var(--fonts-lg);
font-weight: var(--semi-bold);
color: var(--text-lighter);
margin-block-end: 2px;
}
.tournament__team__set__mode {
background-color: var(--bg-lighter);
border-radius: 100%;
padding: var(--s-2);
border: 2px solid var(--theme-success);
}
.tournament__team__set__mode__loss {
border-color: var(--text-lighter);
}
.tournament__team__set__stage-container {
display: flex;
flex-direction: column;
gap: var(--s-2);
align-items: center;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text);
}
.tournament__team__set__opponent {
display: grid;
grid-template-areas: "vs team" "vs members";
grid-template-columns: max-content 1fr;
column-gap: var(--s-4);
row-gap: var(--s-2);
}
.tournament__team__set__opponent__vs {
grid-area: vs;
font-size: var(--fonts-xl);
font-weight: var(--bold);
color: var(--text-lighter);
align-self: center;
}
.tournament__team__set__opponent__team {
grid-area: team;
font-size: var(--fonts-lg);
font-weight: var(--semi-bold);
color: var(--text);
}
.tournament__team__set__opponent__members {
grid-area: members;
display: flex;
gap: var(--s-2);
flex-wrap: wrap;
}
.tournament__team__set__opponent__member {
color: var(--text);
display: flex;
gap: var(--s-1);
font-size: var(--fonts-xs);
align-items: center;
}
.overlap-divider {
display: flex;
width: 100%;
align-items: center;
color: var(--theme);
font-size: var(--fonts-lg);
}
.overlap-divider::before,
.overlap-divider::after {
flex: 1;
border-bottom: 2px solid var(--theme-transparent);
content: "";
}

View File

@ -74,6 +74,10 @@
border-radius: var(--rounded);
}
.rounded-sm {
border-radius: var(--rounded-sm);
}
.rounded-full {
border-radius: 100%;
}

View File

@ -49,3 +49,18 @@ export function isDefined<T>(value: T | undefined | null): value is T {
export function removeDuplicates<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
export function removeDuplicatesByProperty<T>(
arr: T[],
getter: (arg0: T) => number | string
): T[] {
const seen = new Set();
return arr.filter((item) => {
const id = getter(item);
if (seen.has(id)) return false;
seen.add(id);
return true;
});
}

View File

@ -14,7 +14,7 @@ export function parseDBJsonArray(value: any) {
export function parseDBArray(value: any) {
const parsed = JSON.parse(value);
if (parsed.length === 1 && parsed[0] === null) {
if (!parsed || (parsed.length === 1 && parsed[0] === null)) {
return [];
}

View File

@ -179,6 +179,13 @@ export const calendarEditPage = (eventId?: number) =>
export const calendarReportWinnersPage = (eventId: number) =>
`/calendar/${eventId}/report-winners`;
export const tournamentPage = (eventId: number) => `/to/${eventId}`;
export const tournamentTeamPage = ({
eventId,
tournamentTeamId,
}: {
eventId: number;
tournamentTeamId: number;
}) => `/to/${eventId}/teams/${tournamentTeamId}`;
export const tournamentRegisterPage = (eventId: number) =>
`/to/${eventId}/register`;
export const tournamentMapsPage = (eventId: number) => `/to/${eventId}/maps`;

View File

@ -33,6 +33,7 @@
"bestOf.label.short": "Bo",
"pickInfo.team": "Team {{number}} pick",
"pickInfo.team.specific": "{{team}} picked",
"pickInfo.tiebreaker": "Tiebreaker",
"pickInfo.both": "Both picked",
"pickInfo.default": "Default map",
@ -47,5 +48,16 @@
"admin.actions.CHECK_OUT": "Check out",
"admin.actions.ADD_MEMBER": "Add member",
"admin.actions.REMOVE_MEMBER": "Remove member",
"admin.actions.DELETE_TEAM": "Delete team"
"admin.actions.DELETE_TEAM": "Delete team",
"bracket.winners": "Winners Round {{round}}",
"bracket.winners.finals": "Winners Finals",
"bracket.losers": "Losers Round {{round}}",
"bracket.losers.finals": "Losers Finals",
"bracket.single_elim": "Round {{round}}",
"bracket.single_elim.finals": "Finals",
"bracket.grand_finals": "Grand Finals",
"bracket.grand_finals.bracket_reset": "Bracket Reset",
"bracket.main": "Main Bracket",
"bracket.underground": "Underground Bracket"
}

View File

@ -21,6 +21,10 @@ module.exports = {
"features/tournament/routes/to.$id.register.tsx"
);
route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx");
route(
"/to/:id/teams/:tid",
"features/tournament/routes/to.$id.teams.$tid.tsx"
);
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
route("/to/:id/admin", "features/tournament/routes/to.$id.admin.tsx");
route("/to/:id/seeds", "features/tournament/routes/to.$id.seeds.tsx");