mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 15:08:44 -05:00
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:
parent
1adb57003c
commit
7342dd1667
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
93
app/features/tournament/components/TeamWithRoster.tsx
Normal file
93
app/features/tournament/components/TeamWithRoster.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
app/features/tournament/core/sets.server.ts
Normal file
176
app/features/tournament/core/sets.server.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
118
app/features/tournament/queries/setHistoryByTeamId.server.ts
Normal file
118
app/features/tournament/queries/setHistoryByTeamId.server.ts
Normal 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
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
243
app/features/tournament/routes/to.$id.teams.$tid.tsx
Normal file
243
app/features/tournament/routes/to.$id.teams.$tid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@
|
|||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.rounded-sm {
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user