Tournament results participation, seasons page with tournaments & team results page (#2424)

This commit is contained in:
Kalle 2025-07-05 12:11:52 +03:00 committed by GitHub
parent 735b506f56
commit 3d2ede6f3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 2214 additions and 632 deletions

View File

@ -4,7 +4,6 @@ import type {
Insertable,
JSONColumnType,
Selectable,
SqlBool,
Updateable,
} from "kysely";
import type { AssociationVisibility } from "~/features/associations/associations-types";
@ -402,6 +401,7 @@ export interface Skill {
season: number;
tournamentId: number | null;
userId: number | null;
createdAt: number | null;
}
export interface SkillTeamUser {
@ -578,16 +578,24 @@ export interface TournamentMatchGameResult {
export interface TournamentMatchGameResultParticipant {
matchGameResultId: number;
userId: number;
// it only started mattering when we added the possibility to join many teams in a tournament, null for legacy events
tournamentTeamId: number | null;
tournamentTeamId: number;
}
export type WinLossParticipationArray = Array<"W" | "L" | null>;
export interface TournamentResult {
isHighlight: Generated<SqlBool>;
isHighlight: Generated<DBBoolean>;
participantCount: number;
placement: number;
tournamentId: number;
tournamentTeamId: number;
/**
* The result of sets in the tournament.
* E.g. ["W", "L", null] would mean the user won the first set, lost the second and did not play the third.
* */
setResults: JSONColumnType<WinLossParticipationArray>;
/** The SP change in total after the finalization of a ranked tournament. */
spDiff: number | null;
userId: number;
}

View File

@ -24,7 +24,6 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import { isDefined } from "~/utils/arrays";
import {
databaseTimestampToDate,
getDateAtNextFullHour,
@ -461,7 +460,7 @@ function DatesInput({ allowMultiDate }: { allowMultiDate?: boolean }) {
// .reverse() is mutating, but map/filter returns a new array anyway.
const lastValidDate = current
.map((e) => e.date)
.filter(isDefined)
.filter((date) => date !== null)
.reverse()[0];
const addedDate = lastValidDate

View File

@ -1,12 +1,21 @@
import { add } from "date-fns";
import type { ExpressionBuilder } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import * as R from "remeda";
import { db } from "~/db/sql";
import type {
DB,
ParsedMemento,
QWeaponPool,
Tables,
UserSkillDifference,
} from "~/db/tables";
import * as Seasons from "~/features/mmr/core/Seasons";
import { mostPopularArrayElement } from "~/utils/arrays";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
import type { Unpacked } from "~/utils/types";
import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants";
export function findById(id: number) {
return db
@ -177,3 +186,245 @@ export function groupMembersNoScreenSettings(groups: GroupForMatch[]) {
)
.execute();
}
// xxx: this new implementation does not show in progress and canceled matches
/**
* Retrieves the pages count of results for a specific user and season. Counting both SendouQ matches and ranked tournaments.
*/
export async function seasonResultPagesByUserId({
userId,
season,
}: {
userId: number;
season: number;
}): Promise<number> {
const row = await db
.selectFrom("Skill")
.select(({ fn }) => [fn.countAll().as("count")])
.where("userId", "=", userId)
.where("season", "=", season)
.executeTakeFirstOrThrow();
return Math.ceil((row.count as number) / MATCHES_PER_SEASONS_PAGE);
}
const tournamentResultsSubQuery = (
eb: ExpressionBuilder<DB, "Skill">,
userId: number,
) =>
eb
.selectFrom("TournamentResult")
.innerJoin(
"CalendarEvent",
"TournamentResult.tournamentId",
"CalendarEvent.tournamentId",
)
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.leftJoin(
"UserSubmittedImage",
"CalendarEvent.avatarImgId",
"UserSubmittedImage.id",
)
.select([
"TournamentResult.spDiff",
"TournamentResult.setResults",
"TournamentResult.tournamentId",
"TournamentResult.tournamentTeamId",
"CalendarEventDate.startTime as tournamentStartTime",
"CalendarEvent.name as tournamentName",
"UserSubmittedImage.url as logoUrl",
])
.whereRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
.where("TournamentResult.userId", "=", userId);
const groupMatchResultsSubQuery = (eb: ExpressionBuilder<DB, "Skill">) => {
const groupMembersSubQuery = (
eb: ExpressionBuilder<DB, "GroupMatch">,
side: "alpha" | "bravo",
) =>
jsonArrayFrom(
eb
.selectFrom("GroupMember")
.innerJoin("User", "GroupMember.userId", "User.id")
.select([...COMMON_USER_FIELDS])
.whereRef(
"GroupMember.groupId",
"=",
side === "alpha"
? "GroupMatch.alphaGroupId"
: "GroupMatch.bravoGroupId",
),
);
return eb
.selectFrom("GroupMatch")
.select((innerEb) => [
"GroupMatch.id",
"GroupMatch.memento",
"GroupMatch.createdAt",
"GroupMatch.alphaGroupId",
"GroupMatch.bravoGroupId",
groupMembersSubQuery(innerEb, "alpha").as("groupAlphaMembers"),
groupMembersSubQuery(innerEb, "bravo").as("groupBravoMembers"),
jsonArrayFrom(
innerEb
.selectFrom("GroupMatchMap")
.select((innerEb2) => [
"GroupMatchMap.winnerGroupId",
jsonArrayFrom(
innerEb2
.selectFrom("ReportedWeapon")
.select(["ReportedWeapon.userId", "ReportedWeapon.weaponSplId"])
.whereRef(
"ReportedWeapon.groupMatchMapId",
"=",
"GroupMatchMap.id",
),
).as("weapons"),
])
.whereRef("GroupMatchMap.matchId", "=", "GroupMatch.id"),
).as("maps"),
])
.whereRef("Skill.groupMatchId", "=", "GroupMatch.id");
};
export type SeasonGroupMatch = Extract<
Unpacked<Unpacked<ReturnType<typeof seasonResultsByUserId>>>,
{ type: "GROUP_MATCH" }
>["groupMatch"];
export type SeasonTournamentResult = Extract<
Unpacked<Unpacked<ReturnType<typeof seasonResultsByUserId>>>,
{ type: "TOURNAMENT_RESULT" }
>["tournamentResult"];
/**
* Retrieves results of given user, competitive season & page. Both SendouQ matches and ranked tournaments.
*/
export async function seasonResultsByUserId({
userId,
season,
page = 1,
}: {
userId: number;
season: number;
page: number;
}) {
const rows = await db
.selectFrom("Skill")
.select((eb) => [
"Skill.id",
"Skill.createdAt",
jsonObjectFrom(tournamentResultsSubQuery(eb, userId)).as(
"tournamentResult",
),
jsonObjectFrom(groupMatchResultsSubQuery(eb)).as("groupMatch"),
])
.where("userId", "=", userId)
.where("season", "=", season)
.limit(MATCHES_PER_SEASONS_PAGE)
.offset(MATCHES_PER_SEASONS_PAGE * (page - 1))
.orderBy("Skill.id", "desc")
.execute();
return rows.map((row) => {
if (row.groupMatch) {
const skillDiff = row.groupMatch?.memento?.users[userId]?.skillDifference;
const chooseMostPopularWeapon = (userId: number) => {
const weaponSplIds = row
.groupMatch!.maps.flatMap((map) => map.weapons)
.filter((w) => w.userId === userId)
.map((w) => w.weaponSplId);
return mostPopularArrayElement(weaponSplIds);
};
return {
type: "GROUP_MATCH" as const,
...R.omit(row, ["groupMatch", "tournamentResult"]),
// older skills don't have createdAt, so we use groupMatch's createdAt as fallback
createdAt: row.createdAt ?? row.groupMatch.createdAt,
groupMatch: {
...R.omit(row.groupMatch, ["createdAt", "memento", "maps"]),
// note there is no corresponding "censoring logic" for tournament result
// because for those the sp diff is not inserted in the first place
// if it should not be shown to the user
spDiff: skillDiff?.calculated ? skillDiff.spDiff : null,
groupAlphaMembers: row.groupMatch.groupAlphaMembers.map((m) => ({
...m,
weaponSplId: chooseMostPopularWeapon(m.id),
})),
groupBravoMembers: row.groupMatch.groupBravoMembers.map((m) => ({
...m,
weaponSplId: chooseMostPopularWeapon(m.id),
})),
score: row.groupMatch.maps.reduce(
(acc, cur) => [
acc[0] +
(cur.winnerGroupId === row.groupMatch!.alphaGroupId ? 1 : 0),
acc[1] +
(cur.winnerGroupId === row.groupMatch!.bravoGroupId ? 1 : 0),
],
[0, 0],
),
},
};
}
if (row.tournamentResult) {
return {
type: "TOURNAMENT_RESULT" as const,
...R.omit(row, ["groupMatch", "tournamentResult"]),
// older skills don't have createdAt, so we use tournament's start time as a fallback
createdAt: row.createdAt ?? row.tournamentResult.tournamentStartTime,
tournamentResult: row.tournamentResult,
};
}
throw new Error("Row does not contain groupMatch or tournamentResult");
});
}
export async function seasonCanceledMatchesByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
const { starts, ends } = Seasons.nthToDateRange(season);
return db
.selectFrom("GroupMember")
.innerJoin("Group", "GroupMember.groupId", "Group.id")
.innerJoin("GroupMatch", (join) =>
join.on((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
]),
),
)
.innerJoin("Skill", (join) =>
join
.onRef("GroupMatch.id", "=", "Skill.groupMatchId")
// dummy skills used to close match when it's canceled have season -1
.on("Skill.season", "=", -1),
)
.select(["GroupMatch.id", "GroupMatch.createdAt"])
.where("GroupMember.userId", "=", userId)
.where("GroupMatch.createdAt", ">=", dateToDatabaseTimestamp(starts))
.where(
"GroupMatch.createdAt",
"<=",
dateToDatabaseTimestamp(add(ends, { days: 1 })),
)
.orderBy("GroupMatch.createdAt", "desc")
.execute();
}

View File

@ -2,11 +2,12 @@ import { ordinal } from "openskill";
import { sql } from "~/db/sql";
import type { ParsedMemento, Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import { databaseTimestampNow } from "~/utils/dates";
import type { MementoSkillDifferences } from "../core/skills.server";
const getStm = (type: "user" | "team") =>
sql.prepare(/* sql */ `
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "matchesCount")
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "createdAt", "matchesCount")
values (
@groupMatchId,
@identifier,
@ -15,6 +16,7 @@ const getStm = (type: "user" | "team") =>
@sigma,
@ordinal,
@userId,
@createdAt,
1 + coalesce((
select max("matchesCount") from "Skill"
where
@ -65,6 +67,7 @@ export function addSkills({
const stm = skill.userId ? userStm : teamStm;
const insertedSkill = stm.get({
...skill,
createdAt: databaseTimestampNow(),
ordinal: ordinal(skill),
}) as Tables["Skill"];

View File

@ -3,6 +3,7 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { subsOfResult } from "~/features/team/team-utils";
import { databaseTimestampNow } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
@ -102,6 +103,122 @@ export function findByCustomUrl(
.executeTakeFirst();
}
export type FindResultPlacementsById = NonNullable<
Awaited<ReturnType<typeof findResultPlacementsById>>
>;
export function findResultPlacementsById(teamId: number) {
return db
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentResult",
"TournamentResult.tournamentTeamId",
"TournamentTeam.id",
)
.select(["TournamentResult.placement"])
.where("teamId", "=", teamId)
.groupBy("TournamentResult.tournamentId")
.execute();
}
export type FindResultsById = NonNullable<
Awaited<ReturnType<typeof findResultsById>>
>;
/**
* Retrieves tournament results for a given team by its ID.
*/
export async function findResultsById(teamId: number) {
const rows = await db
.with("results", (db) =>
db
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentResult",
"TournamentResult.tournamentTeamId",
"TournamentTeam.id",
)
.select([
"TournamentResult.userId",
"TournamentResult.tournamentTeamId",
"TournamentResult.tournamentId",
"TournamentResult.placement",
"TournamentResult.participantCount",
])
.where("teamId", "=", teamId)
.groupBy("TournamentResult.tournamentId"),
)
.selectFrom("results")
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"results.tournamentId",
)
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select((eb) => [
"results.placement",
"results.tournamentId",
"results.participantCount",
"results.tournamentTeamId",
"CalendarEvent.name as tournamentName",
"CalendarEventDate.startTime",
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
jsonArrayFrom(
eb
.selectFrom("results as results2")
.innerJoin("TournamentResult", (join) =>
join
.onRef(
"TournamentResult.tournamentTeamId",
"=",
"results2.tournamentTeamId",
)
.onRef(
"TournamentResult.tournamentId",
"=",
"results2.tournamentId",
),
)
.innerJoin("User", "User.id", "TournamentResult.userId")
.whereRef("results2.tournamentId", "=", "results.tournamentId")
.select(COMMON_USER_FIELDS),
).as("participants"),
])
.orderBy("CalendarEventDate.startTime", "desc")
.execute();
const members = await allMembersById(teamId);
return rows.map((row) => {
const subs = subsOfResult(row, members);
return {
...row,
subs,
};
});
}
function allMembersById(teamId: number) {
return db
.selectFrom("TeamMemberWithSecondary")
.select([
"TeamMemberWithSecondary.userId",
"TeamMemberWithSecondary.leftAt",
"TeamMemberWithSecondary.createdAt",
])
.where("TeamMemberWithSecondary.teamId", "=", teamId)
.execute();
}
export async function teamsByMemberUserId(
userId: number,
trx?: Transaction<DB>,

View File

@ -0,0 +1,29 @@
import { useMatches } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
import invariant from "~/utils/invariant";
import { teamPage } from "~/utils/urls";
export function TeamGoBackButton() {
const { t } = useTranslation(["common"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as TeamLoaderData;
return (
<div className="stack">
<LinkButton
to={teamPage(layoutData.team.customUrl)}
icon={<ArrowLeftIcon />}
variant="outlined"
size="small"
className="mr-auto"
>
{t("common:actions.back")}
</LinkButton>
</div>
);
}

View File

@ -0,0 +1,7 @@
.players {
display: flex;
flex-direction: column;
padding: 0;
gap: var(--s-3);
list-style: none;
}

View File

@ -0,0 +1,122 @@
import { Link } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { UsersIcon } from "~/components/icons/Users";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import {
tournamentLogoUrl,
tournamentTeamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import styles from "./TeamResultsTable.module.css";
interface TeamResultsTableProps {
results: TeamResultsLoaderData["results"];
}
export function TeamResultsTable({ results }: TeamResultsTableProps) {
const { t, i18n } = useTranslation("user");
return (
<Table>
<thead>
<tr>
<th>{t("results.placing")}</th>
<th>{t("results.date")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.subs")}</th>
</tr>
</thead>
<tbody>
{results.map((result) => {
const logoUrl = result.logoUrl
? userSubmittedImage(result.logoUrl)
: HACKY_resolvePicture({ name: result.tournamentName });
return (
<tr key={result.tournamentId}>
<td className="pl-4 whitespace-nowrap">
<div className="stack horizontal xs items-end">
<Placement placement={result.placement} />{" "}
<div className="text-lighter">
/ {result.participantCount}
</div>
</div>
</td>
<td className="whitespace-nowrap">
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "short",
year: "numeric",
},
)}
</td>
<td>
<div className="stack horizontal xs items-center">
{logoUrl !== tournamentLogoUrl("default") ? (
<img
src={logoUrl}
alt=""
width={18}
height={18}
className="rounded-full"
/>
) : null}
<Link
to={tournamentTeamPage({
tournamentTeamId: result.tournamentTeamId,
tournamentId: result.tournamentId,
})}
>
{result.tournamentName}
</Link>
</div>
</td>
<td>
{result.subs.length > 0 ? (
<div className="stack horizontal md items-center">
<SendouPopover
trigger={
<SendouButton
icon={<UsersIcon />}
size="small"
variant="minimal"
>
{result.subs.length}
</SendouButton>
}
>
<ul className={styles.players}>
{result.subs.map((player) => (
<li key={player.id} className="flex items-center">
<Link
to={userPage(player)}
className="stack horizontal xs items-center"
>
<Avatar user={player} size="xxs" />
{player.username}
</Link>
</li>
))}
</ul>
</SendouPopover>
</div>
) : null}
</td>
</tr>
);
})}
</tbody>
</Table>
);
}

View File

@ -0,0 +1,19 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
export type TeamResultsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
const results = await TeamRepository.findResultsById(team.id);
return {
results,
};
};

View File

@ -7,8 +7,6 @@ import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
import { isTeamManager } from "../team-utils";
import "../team.css";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);

View File

@ -1,13 +1,50 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors } from "../team-utils";
export type TeamLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
return { team, css: canAddCustomizedColors(team) ? team.css : null };
const results = await TeamRepository.findResultPlacementsById(team.id);
return {
team,
css: canAddCustomizedColors(team) ? team.css : null,
results: resultsMapped(results),
};
};
function resultsMapped(results: TeamRepository.FindResultPlacementsById) {
if (results.length === 0) {
return null;
}
const firstPlaceResults = results.filter((result) => result.placement === 1);
const secondPlaceResults = results.filter((result) => result.placement === 2);
const thirdPlaceResults = results.filter((result) => result.placement === 3);
return {
count: results.length,
placements: [
{
placement: 1,
count: firstPlaceResults.length,
},
{
placement: 2,
count: secondPlaceResults.length,
},
{
placement: 3,
count: thirdPlaceResults.length,
},
],
};
}

View File

@ -1,4 +1,4 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import type { MetaFunction } from "@remix-run/node";
import { Form, Link, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@ -12,18 +12,12 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
navIconUrl,
TEAM_SEARCH_PAGE,
teamPage,
uploadImagePage,
} from "~/utils/urls";
import { uploadImagePage } from "~/utils/urls";
import { TEAM } from "../team-constants";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
import "../team.css";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/t.$customUrl.edit.server";
import { loader } from "../loaders/t.$customUrl.edit.server";
export { action, loader };
@ -35,67 +29,48 @@ export const meta: MetaFunction = (args) => {
});
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("t"),
href: TEAM_SEARCH_PAGE,
type: "IMAGE",
},
{
text: data.team.name,
href: teamPage(data.team.customUrl),
type: "TEXT",
},
];
},
};
export default function EditTeamPage() {
const { t } = useTranslation(["common", "team"]);
const user = useUser();
const { team, css } = useLoaderData<typeof loader>();
return (
<Main className="half-width">
{isTeamOwner({ team, user }) ? (
<FormWithConfirm
dialogHeading={t("team:deleteTeam.header", { teamName: team.name })}
fields={[["_action", "DELETE_TEAM"]]}
>
<SendouButton
className="ml-auto"
variant="minimal-destructive"
data-testid="delete-team-button"
<Main className="stack lg">
<TeamGoBackButton />
<div className="half-width">
{isTeamOwner({ team, user }) ? (
<FormWithConfirm
dialogHeading={t("team:deleteTeam.header", { teamName: team.name })}
fields={[["_action", "DELETE_TEAM"]]}
>
{t("team:actionButtons.deleteTeam")}
</SendouButton>
</FormWithConfirm>
) : null}
<Form method="post" className="stack md items-start">
<ImageUploadLinks />
<ImageRemoveButtons />
{canAddCustomizedColors(team) ? (
<CustomizedColorsInput initialColors={css} />
<SendouButton
className="ml-auto"
variant="minimal-destructive"
data-testid="delete-team-button"
>
{t("team:actionButtons.deleteTeam")}
</SendouButton>
</FormWithConfirm>
) : null}
<NameInput />
<BlueskyInput />
<BioTextarea />
<SubmitButton
className="mt-4"
_action="EDIT"
testId="edit-team-submit-button"
>
{t("common:actions.submit")}
</SubmitButton>
<FormErrors namespace="team" />
</Form>
<Form method="post" className="stack md items-start">
<ImageUploadLinks />
<ImageRemoveButtons />
{canAddCustomizedColors(team) ? (
<CustomizedColorsInput initialColors={css} />
) : null}
<NameInput />
<BlueskyInput />
<BioTextarea />
<SubmitButton
className="mt-4"
_action="EDIT"
testId="edit-team-submit-button"
>
{t("common:actions.submit")}
</SubmitButton>
<FormErrors namespace="team" />
</Form>
</div>
</Main>
);
}

View File

@ -0,0 +1,251 @@
import { Link, useFetcher, useMatches } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { EditIcon } from "~/components/icons/Edit";
import { StarIcon } from "~/components/icons/Star";
import { UsersIcon } from "~/components/icons/Users";
import { Placement } from "~/components/Placement";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useHasRole } from "~/modules/permissions/hooks";
import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls";
import {
isTeamManager,
isTeamMember,
isTeamOwner,
resolveNewOwner,
} from "../team-utils";
import "../team.css";
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
import invariant from "~/utils/invariant";
import { action } from "../actions/t.$customUrl.index.server";
import type * as TeamRepository from "../TeamRepository.server";
export { action };
export default function TeamIndexPage() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as TeamLoaderData;
return (
<div className="stack lg">
<ActionButtons />
{layoutData.results ? (
<ResultsBanner results={layoutData.results} />
) : null}
{layoutData.team.bio ? (
<article data-testid="team-bio">{layoutData.team.bio}</article>
) : null}
<div className="stack lg">
{layoutData.team.members.map((member, i) => (
<React.Fragment key={member.discordId}>
<MemberRow member={member} number={i} />
<MobileMemberCard member={member} />
</React.Fragment>
))}
</div>
</div>
);
}
function ActionButtons() {
const { t } = useTranslation(["team"]);
const user = useUser();
const isAdmin = useHasRole("ADMIN");
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as TeamLoaderData;
const team = layoutData.team;
if (!isTeamMember({ user, team }) && !isAdmin) {
return null;
}
const isMainTeam = team.members.find(
(member) => user?.id === member.id && member.isMainTeam,
);
return (
<div className="team__action-buttons">
{isTeamMember({ user, team }) && !isMainTeam ? (
<ChangeMainTeamButton />
) : null}
{isTeamMember({ user, team }) ? (
<FormWithConfirm
dialogHeading={`${t(
isTeamOwner({ user, team })
? "team:leaveTeam.header.newOwner"
: "team:leaveTeam.header",
{
teamName: team.name,
newOwner: resolveNewOwner(team.members)?.username,
},
)}`}
submitButtonText={t("team:actionButtons.leaveTeam.confirm")}
fields={[["_action", "LEAVE_TEAM"]]}
>
<SendouButton
size="small"
variant="destructive"
data-testid="leave-team-button"
>
{t("team:actionButtons.leaveTeam")}
</SendouButton>
</FormWithConfirm>
) : null}
{isTeamManager({ user, team }) || isAdmin ? (
<LinkButton
size="small"
to={manageTeamRosterPage(team.customUrl)}
variant="outlined"
prefetch="intent"
icon={<UsersIcon />}
testId="manage-roster-button"
>
{t("team:actionButtons.manageRoster")}
</LinkButton>
) : null}
{isTeamManager({ user, team }) || isAdmin ? (
<LinkButton
size="small"
to={editTeamPage(team.customUrl)}
variant="outlined"
prefetch="intent"
icon={<EditIcon />}
testId="edit-team-button"
>
{t("team:actionButtons.editTeam")}
</LinkButton>
) : null}
</div>
);
}
function ChangeMainTeamButton() {
const { t } = useTranslation(["team"]);
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<SubmitButton
_action="MAKE_MAIN_TEAM"
size="small"
variant="outlined"
icon={<StarIcon />}
testId="make-main-team-button"
>
{t("team:actionButtons.makeMainTeam")}
</SubmitButton>
</fetcher.Form>
);
}
function ResultsBanner({
results,
}: {
results: NonNullable<TeamLoaderData["results"]>;
}) {
return (
<Link className="team__results" to="results">
<div>View {results.count} results</div>
<ul className="team__results__placements">
{results.placements.map(({ placement, count }) => {
return (
<li key={placement}>
<Placement placement={placement} />×{count}
</li>
);
})}
</ul>
</Link>
);
}
function MemberRow({
member,
number,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
number: number;
}) {
const { t } = useTranslation(["team"]);
return (
<div
className="team__member"
data-testid={member.isOwner ? `member-owner-${member.id}` : undefined}
>
{member.role ? (
<span
className="team__member__role"
data-testid={`member-row-role-${number}`}
>
{t(`team:roles.${member.role}`)}
</span>
) : null}
<div className="team__member__section">
<Link
to={userPage(member)}
className="team__member__avatar-name-container"
>
<div className="team__member__avatar">
<Avatar user={member} size="md" />
</div>
{member.username}
</Link>
<div className="stack horizontal md">
{member.weapons.map(({ weaponSplId, isFavorite }) => (
<WeaponImage
key={weaponSplId}
variant={isFavorite ? "badge-5-star" : "badge"}
weaponSplId={weaponSplId}
width={48}
height={48}
/>
))}
</div>
</div>
</div>
);
}
function MobileMemberCard({
member,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
}) {
const { t } = useTranslation(["team"]);
return (
<div className="team__member-card__container">
<div className="team__member-card">
<Link to={userPage(member)} className="stack items-center">
<Avatar user={member} size="md" />
<div className="team__member-card__name">{member.username}</div>
</Link>
{member.weapons.length > 0 ? (
<div className="stack horizontal md">
{member.weapons.map(({ weaponSplId, isFavorite }) => (
<WeaponImage
key={weaponSplId}
variant={isFavorite ? "badge-5-star" : "badge"}
weaponSplId={weaponSplId}
width={32}
height={32}
/>
))}
</div>
) : null}
</div>
{member.role ? (
<span className="team__member__role__mobile">
{t(`team:roles.${member.role}`)}
</span>
) : null}
</div>
);
}

View File

@ -0,0 +1,17 @@
import { useLoaderData } from "@remix-run/react";
import { Main } from "~/components/Main";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { TeamResultsTable } from "~/features/team/components/TeamResultsTable";
import { loader } from "../loaders/t.$customUrl.results.server";
export { loader };
export default function TeamResultsPage() {
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<TeamGoBackButton />
<TeamResultsTable results={data.results} />
</Main>
);
}

View File

@ -1,4 +1,4 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import type { MetaFunction } from "@remix-run/node";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
@ -13,17 +13,12 @@ import { TrashIcon } from "~/components/icons/Trash";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
joinTeamPage,
navIconUrl,
TEAM_SEARCH_PAGE,
teamPage,
} from "~/utils/urls";
import { joinTeamPage } from "~/utils/urls";
import type * as TeamRepository from "../TeamRepository.server";
import { TEAM_MEMBER_ROLES } from "../team-constants";
import { isTeamFull } from "../team-utils";
import "../team.css";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/t.$customUrl.roster.server";
@ -37,33 +32,12 @@ export const meta: MetaFunction = (args) => {
});
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("t"),
href: TEAM_SEARCH_PAGE,
type: "IMAGE",
},
{
text: data.team.name,
href: teamPage(data.team.customUrl),
type: "TEXT",
},
];
},
};
export default function ManageTeamRosterPage() {
const { t } = useTranslation(["team"]);
return (
<Main className="stack lg">
<TeamGoBackButton />
<InviteCodeSection />
<MemberActions />
<SendouPopover

View File

@ -10,7 +10,7 @@ import {
wrappedLoader,
} from "~/utils/Test";
import { loader as userProfileLoader } from "../../user-page/loaders/u.$identifier.index.server";
import { action as _teamPageAction } from "../actions/t.$customUrl.server";
import { action as _teamPageAction } from "../actions/t.$customUrl.index.server";
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import * as TeamRepository from "../TeamRepository.server";

View File

@ -1,46 +1,23 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import { Outlet, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { BskyIcon } from "~/components/icons/Bsky";
import { EditIcon } from "~/components/icons/Edit";
import { StarIcon } from "~/components/icons/Star";
import { UsersIcon } from "~/components/icons/Users";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
bskyUrl,
editTeamPage,
manageTeamRosterPage,
navIconUrl,
TEAM_SEARCH_PAGE,
teamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import type * as TeamRepository from "../TeamRepository.server";
import {
isTeamManager,
isTeamMember,
isTeamOwner,
resolveNewOwner,
} from "../team-utils";
import "../team.css";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/t.$customUrl.server";
import { loader } from "../loaders/t.$customUrl.server";
export { action, loader };
export { loader };
import "../team.css";
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
@ -84,26 +61,13 @@ export const handle: SendouRouteHandle = {
};
export default function TeamPage() {
const { team } = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<Main className="stack sm">
<div className="stack sm">
<TeamBanner />
{/* <InfoBadges /> */}
</div>
<MobileTeamNameCountry />
<ActionButtons />
{/* {team.results ? <ResultsBanner results={team.results} /> : null} */}
{team.bio ? <article data-testid="team-bio">{team.bio}</article> : null}
<div className="stack lg">
{team.members.map((member, i) => (
<React.Fragment key={member.discordId}>
<MemberRow member={member} number={i} />
<MobileMemberCard member={member} />
</React.Fragment>
))}
</div>
<Outlet />
</Main>
);
}
@ -187,194 +151,3 @@ function BskyLink() {
</a>
);
}
function ActionButtons() {
const { t } = useTranslation(["team"]);
const user = useUser();
const isAdmin = useHasRole("ADMIN");
const { team } = useLoaderData<typeof loader>();
if (!isTeamMember({ user, team }) && !isAdmin) {
return null;
}
const isMainTeam = team.members.find(
(member) => user?.id === member.id && member.isMainTeam,
);
return (
<div className="team__action-buttons">
{isTeamMember({ user, team }) && !isMainTeam ? (
<ChangeMainTeamButton />
) : null}
{isTeamMember({ user, team }) ? (
<FormWithConfirm
dialogHeading={`${t(
isTeamOwner({ user, team })
? "team:leaveTeam.header.newOwner"
: "team:leaveTeam.header",
{
teamName: team.name,
newOwner: resolveNewOwner(team.members)?.username,
},
)}`}
submitButtonText={t("team:actionButtons.leaveTeam.confirm")}
fields={[["_action", "LEAVE_TEAM"]]}
>
<SendouButton
size="small"
variant="destructive"
data-testid="leave-team-button"
>
{t("team:actionButtons.leaveTeam")}
</SendouButton>
</FormWithConfirm>
) : null}
{isTeamManager({ user, team }) || isAdmin ? (
<LinkButton
size="small"
to={manageTeamRosterPage(team.customUrl)}
variant="outlined"
prefetch="intent"
icon={<UsersIcon />}
testId="manage-roster-button"
>
{t("team:actionButtons.manageRoster")}
</LinkButton>
) : null}
{isTeamManager({ user, team }) || isAdmin ? (
<LinkButton
size="small"
to={editTeamPage(team.customUrl)}
variant="outlined"
prefetch="intent"
icon={<EditIcon />}
testId="edit-team-button"
>
{t("team:actionButtons.editTeam")}
</LinkButton>
) : null}
</div>
);
}
function ChangeMainTeamButton() {
const { t } = useTranslation(["team"]);
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<SubmitButton
_action="MAKE_MAIN_TEAM"
size="small"
variant="outlined"
icon={<StarIcon />}
testId="make-main-team-button"
>
{t("team:actionButtons.makeMainTeam")}
</SubmitButton>
</fetcher.Form>
);
}
// function ResultsBanner({ results }: { results: TeamResultPeek }) {
// return (
// <Link className="team__results" to="results">
// <div>View {results.count} results</div>
// <ul className="team__results__placements">
// {results.placements.map(({ placement, count }) => {
// return (
// <li key={placement}>
// <Placement placement={placement} />×{count}
// </li>
// );
// })}
// </ul>
// </Link>
// );
// }
function MemberRow({
member,
number,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
number: number;
}) {
const { t } = useTranslation(["team"]);
return (
<div
className="team__member"
data-testid={member.isOwner ? `member-owner-${member.id}` : undefined}
>
{member.role ? (
<span
className="team__member__role"
data-testid={`member-row-role-${number}`}
>
{t(`team:roles.${member.role}`)}
</span>
) : null}
<div className="team__member__section">
<Link
to={userPage(member)}
className="team__member__avatar-name-container"
>
<div className="team__member__avatar">
<Avatar user={member} size="md" />
</div>
{member.username}
</Link>
<div className="stack horizontal md">
{member.weapons.map(({ weaponSplId, isFavorite }) => (
<WeaponImage
key={weaponSplId}
variant={isFavorite ? "badge-5-star" : "badge"}
weaponSplId={weaponSplId}
width={48}
height={48}
/>
))}
</div>
</div>
</div>
);
}
function MobileMemberCard({
member,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
}) {
const { t } = useTranslation(["team"]);
return (
<div className="team__member-card__container">
<div className="team__member-card">
<Link to={userPage(member)} className="stack items-center">
<Avatar user={member} size="md" />
<div className="team__member-card__name">{member.username}</div>
</Link>
{member.weapons.length > 0 ? (
<div className="stack horizontal md">
{member.weapons.map(({ weaponSplId, isFavorite }) => (
<WeaponImage
key={weaponSplId}
variant={isFavorite ? "badge-5-star" : "badge"}
weaponSplId={weaponSplId}
width={32}
height={32}
/>
))}
</div>
) : null}
</div>
{member.role ? (
<span className="team__member__role__mobile">
{t(`team:roles.${member.role}`)}
</span>
) : null}
</div>
);
}

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { subsOfResult } from "./team-utils";
describe("subsOfResult()", () => {
it("returns empty array if all participants are current members", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startTime: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: null },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([]);
});
it("returns participant not in members as sub", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startTime: 1000,
};
const members = [{ userId: 1, createdAt: 500, leftAt: null }];
const subs = subsOfResult(result, members);
expect(subs).toEqual([{ id: 2 }]);
});
it("returns participant as sub if they left before result startTime", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startTime: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: 900 },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([{ id: 1 }]);
});
it("does not return participant as sub if they were a member during result", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startTime: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: 2000 },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([]);
});
it("returns multiple subs correctly", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }, { id: 3 }],
startTime: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: 900 },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([{ id: 1 }, { id: 3 }]);
});
it("returns empty array if no participants", () => {
const result = {
participants: [],
startTime: 1000,
};
const members = [{ userId: 1, createdAt: 500, leftAt: null }];
const subs = subsOfResult(result, members);
expect(subs).toEqual([]);
});
});

View File

@ -1,3 +1,4 @@
import type { Tables } from "~/db/tables";
import type * as TeamRepository from "./TeamRepository.server";
import { TEAM } from "./team-constants";
@ -72,3 +73,40 @@ export function resolveNewOwner(
return null;
}
/**
* Returns a list of participant IDs who are considered "substitutes" for a given tournament result,
* based on the team's member history and the result's participants.
*
* A participant is considered a substitute if both:
* - They are not a current member (i.e., their `leftAt` is set).
* - They are not a past member who was part of the team during the result's start time.
*/
export function subsOfResult<T extends { id: number }>(
result: { participants: Array<T>; startTime: number },
members: Array<Pick<Tables["TeamMember"], "userId" | "createdAt" | "leftAt">>,
) {
const currentMembers = members.filter((member) => !member.leftAt);
const pastMembers = members.filter((member) => member.leftAt);
const subs = result.participants.reduce((acc: Array<T>, cur) => {
if (currentMembers.some((member) => member.userId === cur.id)) return acc;
if (
pastMembers.some(
(member) =>
member.userId === cur.id &&
member.createdAt < result.startTime &&
member.leftAt &&
member.leftAt > result.startTime,
)
) {
return acc;
}
acc.push(cur);
return acc;
}, []);
return subs;
}

View File

@ -174,6 +174,7 @@
justify-content: space-between;
width: min(100%, 48rem);
color: var(--text);
white-space: nowrap;
}
.team__results__placements {

View File

@ -246,7 +246,7 @@ export const action: ActionFunction = async ({ params, request }) => {
queryCurrentTeamRating: (identifier) =>
queryCurrentTeamRating({ identifier, season: season! }).rating,
queryCurrentUserRating: (userId) =>
queryCurrentUserRating({ userId, season: season! }).rating,
queryCurrentUserRating({ userId, season: season! }),
queryTeamPlayerRatingAverage: (identifier) =>
queryTeamPlayerRatingAverage({
identifier,

View File

@ -1,13 +1,15 @@
import type { Rating } from "node_modules/openskill/dist/types";
import { ordinal } from "openskill";
import * as R from "remeda";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import {
identifierToUserIds,
ordinalToSp,
rate,
userIdsToIdentifier,
} from "~/features/mmr/mmr-utils";
import invariant from "~/utils/invariant";
import type { Tables } from "../../../db/tables";
import { roundToNDecimalPlaces } from "~/utils/number";
import type { Tables, WinLossParticipationArray } from "../../../db/tables";
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
import { ensureOneStandingPerUser } from "../tournament-bracket-utils";
import type { Standing } from "./Bracket";
@ -15,24 +17,32 @@ import type { Standing } from "./Bracket";
export interface TournamentSummary {
skills: Omit<
Tables["Skill"],
"tournamentId" | "id" | "ordinal" | "season" | "groupMatchId"
"tournamentId" | "id" | "ordinal" | "season" | "groupMatchId" | "createdAt"
>[];
seedingSkills: Tables["SeedingSkill"][];
mapResultDeltas: Omit<Tables["MapResult"], "season">[];
playerResultDeltas: Omit<Tables["PlayerResult"], "season">[];
tournamentResults: Omit<
Tables["TournamentResult"],
"tournamentId" | "isHighlight"
"tournamentId" | "isHighlight" | "spDiff" | "mapResults" | "setResults"
>[];
/** Map of user id to diff or null if not ranked event */
spDiffs: Map<number, number> | null;
/** Map of user id to set results */
setResults: Map<number, WinLossParticipationArray>;
}
type UserIdToTeamId = Record<number, number>;
type TeamsArg = Array<{
id: number;
members: Array<{ userId: number }>;
}>;
type Rating = Pick<Tables["Skill"], "mu" | "sigma">;
type RatingWithMatchesCount = {
rating: Rating;
matchesCount: number;
};
export function tournamentSummary({
results,
teams,
@ -49,23 +59,28 @@ export function tournamentSummary({
finalStandings: Standing[];
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
queryCurrentUserRating: (userId: number) => RatingWithMatchesCount;
queryCurrentSeedingRating: (userId: number) => Rating;
seedingSkillCountsFor: Tables["SeedingSkill"]["type"] | null;
calculateSeasonalStats?: boolean;
}): TournamentSummary {
const skills = calculateSeasonalStats
? calculateSkills({
results,
queryCurrentTeamRating,
queryCurrentUserRating,
queryTeamPlayerRatingAverage,
})
: [];
return {
skills: calculateSeasonalStats
? skills({
results,
queryCurrentTeamRating,
queryCurrentUserRating,
queryTeamPlayerRatingAverage,
})
: [],
skills,
seedingSkills: seedingSkillCountsFor
? calculateIndividualPlayerSkills({
queryCurrentUserRating: queryCurrentSeedingRating,
queryCurrentUserRating: (userId) => ({
rating: queryCurrentSeedingRating(userId),
matchesCount: 0, // Seeding skills do not have matches count
}),
results,
}).map((skill) => ({
...skill,
@ -81,26 +96,18 @@ export function tournamentSummary({
participantCount: teams.length,
finalStandings: ensureOneStandingPerUser(finalStandings),
}),
spDiffs: calculateSeasonalStats
? spDiffs({ skills, queryCurrentUserRating })
: null,
setResults: setResults({ results, teams }),
};
}
export function userIdsToTeamIdRecord(teams: TeamsArg) {
const result: UserIdToTeamId = {};
for (const team of teams) {
for (const member of team.members) {
result[member.userId] = team.id;
}
}
return result;
}
function skills(args: {
function calculateSkills(args: {
results: AllMatchResult[];
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
queryCurrentUserRating: (userId: number) => RatingWithMatchesCount;
}) {
const result: TournamentSummary["skills"] = [];
@ -115,7 +122,7 @@ export function calculateIndividualPlayerSkills({
queryCurrentUserRating,
}: {
results: AllMatchResult[];
queryCurrentUserRating: (userId: number) => Rating;
queryCurrentUserRating: (userId: number) => RatingWithMatchesCount;
}) {
const userRatings = new Map<number, Rating>();
const userMatchesCount = new Map<number, number>();
@ -123,26 +130,11 @@ export function calculateIndividualPlayerSkills({
const existingRating = userRatings.get(userId);
if (existingRating) return existingRating;
return queryCurrentUserRating(userId);
return queryCurrentUserRating(userId).rating;
};
for (const match of results) {
const winnerTeamId =
match.opponentOne.result === "win"
? match.opponentOne.id
: match.opponentTwo.id;
const participants = match.maps.flatMap((m) => m.participants);
const winnerUserIds = R.unique(
participants
.filter((p) => p.tournamentTeamId === winnerTeamId)
.map((p) => p.userId),
);
const loserUserIds = R.unique(
participants
.filter((p) => p.tournamentTeamId !== winnerTeamId)
.map((p) => p.userId),
);
const { winnerUserIds, loserUserIds } = matchToSetMostPlayedUsers(match);
const [ratedWinners, ratedLosers] = rate([
winnerUserIds.map(getUserRating),
@ -180,6 +172,58 @@ export function calculateIndividualPlayerSkills({
});
}
/**
* Determines the most frequently appearing user IDs for both the winning and losing teams in a match/set.
*
* For each team (winner and loser), this function collects all user IDs from the match's map participants,
* counts their occurrences, and returns the most popular user IDs up to a full team's worth depending on the tournament format (4v4, 3v3 etc.).
* If there are ties at the cutoff, all tied user IDs are included.
*/
function matchToSetMostPlayedUsers(match: AllMatchResult) {
const resolveMostPopularUserIds = (userIds: number[]) => {
const counts = userIds.reduce((acc, userId) => {
acc.set(userId, (acc.get(userId) ?? 0) + 1);
return acc;
}, new Map<number, number>());
const sorted = Array.from(counts.entries()).sort(
([, countA], [, countB]) => countB - countA,
);
const targetAmount = Math.ceil(match.maps[0].participants.length / 2);
const result: number[] = [];
let previousCount = 0;
for (const [userId, count] of sorted) {
// take target amount of most popular users
// or more if there are ties
if (result.length >= targetAmount && count < previousCount) break;
result.push(userId);
previousCount = count;
}
return result;
};
const winnerTeamId =
match.opponentOne.result === "win"
? match.opponentOne.id
: match.opponentTwo.id;
const participants = match.maps.flatMap((m) => m.participants);
const winnerUserIds = participants
.filter((p) => p.tournamentTeamId === winnerTeamId)
.map((p) => p.userId);
const loserUserIds = participants
.filter((p) => p.tournamentTeamId !== winnerTeamId)
.map((p) => p.userId);
return {
winnerUserIds: resolveMostPopularUserIds(winnerUserIds),
loserUserIds: resolveMostPopularUserIds(loserUserIds),
};
}
function calculateTeamSkills({
results,
queryCurrentTeamRating,
@ -470,3 +514,84 @@ function tournamentResults({
return result;
}
function spDiffs({
skills,
queryCurrentUserRating,
}: {
skills: TournamentSummary["skills"];
queryCurrentUserRating: (userId: number) => RatingWithMatchesCount;
}): TournamentSummary["spDiffs"] {
const spDiffs = new Map<number, number>();
for (const skill of skills) {
if (skill.userId === null) continue;
const oldRating = queryCurrentUserRating(skill.userId);
// there should be no user visible sp diff if the user has less than
// MATCHES_COUNT_NEEDED_FOR_LEADERBOARD matches played before because
// the sp is not visible to user before that threshold
if (oldRating.matchesCount < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD) {
continue;
}
const diff = roundToNDecimalPlaces(
ordinalToSp(ordinal(skill)) - ordinalToSp(ordinal(oldRating.rating)),
);
spDiffs.set(skill.userId, diff);
}
return spDiffs;
}
export function setResults({
results,
teams,
}: {
results: AllMatchResult[];
teams: TeamsArg;
}) {
const setResults = new Map<number, WinLossParticipationArray>();
const addToMap = (
userId: number,
result: WinLossParticipationArray[number],
) => {
const existing = setResults.get(userId) ?? [];
existing.push(result);
setResults.set(userId, existing);
};
for (const match of results) {
const allMatchUserIds = teams.flatMap((team) => {
const didParticipateInTheMatch =
match.opponentOne.id === team.id || match.opponentTwo.id === team.id;
if (!didParticipateInTheMatch) return [];
return teamIdToMembersUserIds(teams, team.id);
});
const { winnerUserIds, loserUserIds } = matchToSetMostPlayedUsers(match);
const subbedOut = allMatchUserIds.filter(
(userId) =>
!winnerUserIds.some((wUserId) => wUserId === userId) &&
!loserUserIds.some((lUserId) => lUserId === userId),
);
for (const winnerUserId of winnerUserIds) addToMap(winnerUserId, "W");
for (const loserUserId of loserUserIds) addToMap(loserUserId, "L");
for (const subUserId of subbedOut) addToMap(subUserId, null);
}
return setResults;
}
function teamIdToMembersUserIds(teams: TeamsArg, teamId: number) {
const team = teams.find((t) => t.id === teamId);
invariant(team, `Team with id ${teamId} not found`);
return team.members.map((m) => m.userId);
}

View File

@ -153,7 +153,7 @@ describe("tournamentSummary()", () => {
},
],
queryCurrentTeamRating: () => rating(),
queryCurrentUserRating: () => rating(),
queryCurrentUserRating: () => ({ rating: rating(), matchesCount: 0 }),
queryTeamPlayerRatingAverage: () => rating(),
queryCurrentSeedingRating: () => rating(),
seedingSkillCountsFor: seedingSkillCountsFor ?? null,
@ -467,4 +467,158 @@ describe("tournamentSummary()", () => {
expect(result.length).toBe(2);
expect(result.every((r) => r.wins === 1 && r.losses === 0)).toBeTruthy();
});
test("calculates set results array", () => {
const summary = summarize();
const winner = summary.setResults.get(1);
const loser = summary.setResults.get(5);
const sub = summary.setResults.get(20);
invariant(winner, "winner should be defined");
invariant(loser, "loser should be defined");
invariant(sub, "sub should be defined");
expect(winner).toEqual(["W"]);
expect(loser).toEqual(["L"]);
expect(sub).toEqual([null]);
});
test("playing for many teams should include combined sets in the set results array", () => {
const summary = summarize({
withMemberInTwoTeams: true,
results: resultsWith20,
});
const results = summary.setResults.get(20);
// only sub for the first team (null) and winning for the second team (W)
expect(results).toEqual([null, "W"]);
});
test("playing minority of maps in a set should not be count for set results", () => {
const summary = summarize({
results: [
{
maps: [
{
mode: "SZ",
stageId: 1,
participants: [
{ tournamentTeamId: 1, userId: 1 },
{ tournamentTeamId: 1, userId: 2 },
{ tournamentTeamId: 1, userId: 3 },
{ tournamentTeamId: 1, userId: 4 },
{ tournamentTeamId: 2, userId: 5 },
{ tournamentTeamId: 2, userId: 6 },
{ tournamentTeamId: 2, userId: 7 },
{ tournamentTeamId: 2, userId: 8 },
],
winnerTeamId: 1,
},
{
mode: "SZ",
stageId: 1,
participants: [
{ tournamentTeamId: 1, userId: 20 },
{ tournamentTeamId: 1, userId: 2 },
{ tournamentTeamId: 1, userId: 3 },
{ tournamentTeamId: 1, userId: 4 },
{ tournamentTeamId: 2, userId: 5 },
{ tournamentTeamId: 2, userId: 6 },
{ tournamentTeamId: 2, userId: 7 },
{ tournamentTeamId: 2, userId: 8 },
],
winnerTeamId: 1,
},
{
mode: "SZ",
stageId: 1,
participants: [
{ tournamentTeamId: 1, userId: 20 },
{ tournamentTeamId: 1, userId: 2 },
{ tournamentTeamId: 1, userId: 3 },
{ tournamentTeamId: 1, userId: 4 },
{ tournamentTeamId: 2, userId: 5 },
{ tournamentTeamId: 2, userId: 6 },
{ tournamentTeamId: 2, userId: 7 },
{ tournamentTeamId: 2, userId: 8 },
],
winnerTeamId: 1,
},
],
opponentOne: {
id: 1,
result: "win",
score: 3,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
},
],
});
const results = summary.setResults.get(1);
expect(results).toEqual([null]);
});
test("playing in half the maps should be enough to count for set results", () => {
const summary = summarize({
results: [
{
maps: [
{
mode: "SZ",
stageId: 1,
participants: [
{ tournamentTeamId: 1, userId: 1 },
{ tournamentTeamId: 1, userId: 2 },
{ tournamentTeamId: 1, userId: 3 },
{ tournamentTeamId: 1, userId: 4 },
{ tournamentTeamId: 2, userId: 5 },
{ tournamentTeamId: 2, userId: 6 },
{ tournamentTeamId: 2, userId: 7 },
{ tournamentTeamId: 2, userId: 8 },
],
winnerTeamId: 1,
},
{
mode: "SZ",
stageId: 1,
participants: [
{ tournamentTeamId: 1, userId: 20 },
{ tournamentTeamId: 1, userId: 2 },
{ tournamentTeamId: 1, userId: 3 },
{ tournamentTeamId: 1, userId: 4 },
{ tournamentTeamId: 2, userId: 5 },
{ tournamentTeamId: 2, userId: 6 },
{ tournamentTeamId: 2, userId: 7 },
{ tournamentTeamId: 2, userId: 8 },
],
winnerTeamId: 1,
},
],
opponentOne: {
id: 1,
result: "win",
score: 2,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
},
],
});
for (const userId of [1, 20]) {
const results = summary.setResults.get(userId);
invariant(results, `results for user ${userId} should be defined`);
expect(results).toEqual(["W"]);
}
});
});

View File

@ -2,6 +2,7 @@ import { ordinal } from "openskill";
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import { databaseTimestampNow } from "~/utils/dates";
import type { TournamentSummary } from "../core/summarizer.server";
const addSkillStm = sql.prepare(/* sql */ `
@ -13,7 +14,8 @@ const addSkillStm = sql.prepare(/* sql */ `
"userId",
"identifier",
"matchesCount",
"season"
"season",
"createdAt"
)
values (
@tournamentId,
@ -23,7 +25,8 @@ const addSkillStm = sql.prepare(/* sql */ `
@userId,
@identifier,
@matchesCount + coalesce((select max("matchesCount") from "Skill" where "userId" = @userId or "identifier" = @identifier group by "userId", "identifier"), 0),
@season
@season,
@createdAt
) returning *
`);
@ -110,13 +113,17 @@ const addTournamentResultStm = sql.prepare(/* sql */ `
"userId",
"placement",
"participantCount",
"tournamentTeamId"
"tournamentTeamId",
"setResults",
"spDiff"
) values (
@tournamentId,
@userId,
@placement,
@participantCount,
@tournamentTeamId
@tournamentTeamId,
@setResults,
@spDiff
)
`);
@ -146,6 +153,7 @@ export const addSummary = sql.transaction(
identifier: skill.identifier ?? null,
matchesCount: skill.matchesCount,
season: season ?? null,
createdAt: databaseTimestampNow(),
}) as Tables["Skill"];
if (insertedSkill.identifier) {
@ -193,12 +201,20 @@ export const addSummary = sql.transaction(
}
for (const tournamentResult of summary.tournamentResults) {
const setResults = summary.setResults.get(tournamentResult.userId);
if (setResults?.every((result) => !result)) {
continue;
}
addTournamentResultStm.run({
tournamentId,
userId: tournamentResult.userId,
placement: tournamentResult.placement,
participantCount: tournamentResult.participantCount,
tournamentTeamId: tournamentResult.tournamentTeamId,
setResults: setResults ? JSON.stringify(setResults) : null,
spDiff: summary.spDiffs?.get(tournamentResult.userId) ?? null,
});
}

View File

@ -46,7 +46,7 @@ const stm = sql.prepare(/* sql */ `
and "opponentOneResult" is not null
group by "m"."id"
order by "m"."id" asc
`);
`); // strictly speaking the order by condition is not accurate, future improvement would be to add order conditions that match the tournament structure
interface Opponent {
id: number;
@ -94,6 +94,14 @@ export function allMatchResultsByTournamentId(
),
"Some participants have no team id",
);
invariant(
participants.every(
(p: any) =>
p.tournamentTeamId === row.opponentOneId ||
p.tournamentTeamId === row.opponentTwoId,
),
"Some participants have an invalid team id",
);
return {
...map,

View File

@ -432,8 +432,11 @@ const withMaxEventStartTime = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
.whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id")
.as("startTime");
};
export function findResultsByUserId(userId: number) {
return db
export function findResultsByUserId(
userId: number,
{ showHighlightsOnly = false }: { showHighlightsOnly?: boolean } = {},
) {
let calendarEventResultsQuery = db
.selectFrom("CalendarEventResultPlayer")
.innerJoin(
"CalendarEventResultTeam",
@ -445,25 +448,27 @@ export function findResultsByUserId(userId: number) {
"CalendarEvent.id",
"CalendarEventResultTeam.eventId",
)
.select(({ eb, exists, selectFrom }) => [
.leftJoin("UserResultHighlight", (join) =>
join
.onRef("UserResultHighlight.teamId", "=", "CalendarEventResultTeam.id")
.on("UserResultHighlight.userId", "=", userId),
)
.select(({ eb, fn }) => [
"CalendarEvent.id as eventId",
sql<number>`null`.as("tournamentId"),
"CalendarEventResultTeam.placement",
"CalendarEvent.participantCount",
sql<Tables["TournamentResult"]["setResults"]>`null`.as("setResults"),
sql<string | null>`null`.as("logoUrl"),
"CalendarEvent.name as eventName",
"CalendarEventResultTeam.id as teamId",
"CalendarEventResultTeam.name as teamName",
fn<number | null>("iif", [
"UserResultHighlight.userId",
sql`1`,
sql`0`,
]).as("isHighlight"),
withMaxEventStartTime(eb),
exists(
selectFrom("UserResultHighlight")
.where("UserResultHighlight.userId", "=", userId)
.whereRef(
"UserResultHighlight.teamId",
"=",
"CalendarEventResultTeam.id",
)
.select("UserResultHighlight.userId"),
).as("isHighlight"),
jsonArrayFrom(
eb
.selectFrom("CalendarEventResultPlayer")
@ -482,53 +487,94 @@ export function findResultsByUserId(userId: number) {
),
).as("mates"),
])
.where("CalendarEventResultPlayer.userId", "=", userId)
.unionAll(
db
.selectFrom("TournamentResult")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentResult.tournamentTeamId",
)
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentResult.tournamentId",
)
.select(({ eb }) => [
sql<number>`null`.as("eventId"),
"TournamentResult.tournamentId",
"TournamentResult.placement",
"TournamentResult.participantCount",
"CalendarEvent.name as eventName",
"TournamentTeam.id as teamId",
"TournamentTeam.name as teamName",
withMaxEventStartTime(eb),
"TournamentResult.isHighlight",
jsonArrayFrom(
eb
.selectFrom("TournamentResult as TournamentResult2")
.innerJoin("User", "User.id", "TournamentResult2.userId")
.select([
...COMMON_USER_FIELDS,
sql<string | null>`null`.as("name"),
])
.whereRef(
"TournamentResult2.tournamentTeamId",
"=",
"TournamentResult.tournamentTeamId",
)
.where("TournamentResult2.userId", "!=", userId),
).as("mates"),
])
.where("TournamentResult.userId", "=", userId),
.where("CalendarEventResultPlayer.userId", "=", userId);
let tournamentResultsQuery = db
.selectFrom("TournamentResult")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentResult.tournamentTeamId",
)
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentResult.tournamentId",
)
.select(({ eb }) => [
sql<number>`null`.as("eventId"),
"TournamentResult.tournamentId",
"TournamentResult.placement",
"TournamentResult.participantCount",
"TournamentResult.setResults",
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
"CalendarEvent.name as eventName",
"TournamentTeam.id as teamId",
"TournamentTeam.name as teamName",
"TournamentResult.isHighlight",
withMaxEventStartTime(eb),
jsonArrayFrom(
eb
.selectFrom("TournamentResult as TournamentResult2")
.innerJoin("User", "User.id", "TournamentResult2.userId")
.select([...COMMON_USER_FIELDS, sql<string | null>`null`.as("name")])
.whereRef(
"TournamentResult2.tournamentTeamId",
"=",
"TournamentResult.tournamentTeamId",
)
.where("TournamentResult2.userId", "!=", userId),
).as("mates"),
])
.where("TournamentResult.userId", "=", userId);
if (showHighlightsOnly) {
calendarEventResultsQuery = calendarEventResultsQuery.where(
"UserResultHighlight.userId",
"is not",
null,
);
tournamentResultsQuery = tournamentResultsQuery.where(
"TournamentResult.isHighlight",
"=",
1,
);
}
return calendarEventResultsQuery
.unionAll(tournamentResultsQuery)
.orderBy("startTime", "desc")
.$narrowType<{ startTime: NotNull }>()
.execute();
}
export async function hasHighlightedResultsByUserId(userId: number) {
const highlightedTournamentResult = await db
.selectFrom("TournamentResult")
.where("userId", "=", userId)
.where("isHighlight", "=", 1)
.select("userId")
.limit(1)
.executeTakeFirst();
if (highlightedTournamentResult) {
return true;
}
const highlightedCalendarEventResult = await db
.selectFrom("UserResultHighlight")
.where("userId", "=", userId)
.select(["userId"])
.limit(1)
.executeTakeFirst();
return !!highlightedCalendarEventResult;
}
const searchSelectedFields = ({ fn }: { fn: FunctionModule<DB, "User"> }) =>
[
...COMMON_USER_FIELDS,

View File

@ -1,14 +1,21 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { UsersIcon } from "~/components/icons/Users";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import {
calendarEventPage,
tournamentBracketsPage,
tournamentLogoUrl,
tournamentTeamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server";
@ -36,10 +43,10 @@ export function UserResultsTable({
<tr>
{hasHighlightCheckboxes && <th />}
<th id={placementHeaderId}>{t("results.placing")}</th>
<th>{t("results.team")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.date")}</th>
<th>{t("results.mates")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.participation")}</th>
<th>{t("results.team")}</th>
</tr>
</thead>
<tbody>
@ -52,6 +59,10 @@ export function UserResultsTable({
const nameCellId = `${id}-${result.teamId}-name`;
const checkboxLabelIds = `${nameCellId} ${placementHeaderId} ${placementCellId}`;
const logoUrl = result.logoUrl
? userSubmittedImage(result.logoUrl)
: HACKY_resolvePicture({ name: result.eventName });
return (
<tr key={result.teamId}>
{hasHighlightCheckboxes && (
@ -77,72 +88,99 @@ export function UserResultsTable({
</div>
</div>
</td>
<td>
{result.tournamentId ? (
<Link
to={tournamentTeamPage({
tournamentId: result.tournamentId,
tournamentTeamId: result.teamId,
})}
>
{result.teamName}
</Link>
) : (
result.teamName
)}
</td>
<td id={nameCellId}>
{result.eventId ? (
<Link to={calendarEventPage(result.eventId)}>
{result.eventName}
</Link>
) : null}
{result.tournamentId ? (
<Link
to={tournamentBracketsPage({
tournamentId: result.tournamentId,
})}
data-testid="tournament-name-cell"
>
{result.eventName}
</Link>
) : null}
</td>
<td>
<td className="whitespace-nowrap">
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
month: "short",
year: "numeric",
},
)}
</td>
<td id={nameCellId}>
<div className="stack horizontal xs items-center">
{result.eventId ? (
<Link to={calendarEventPage(result.eventId)}>
{result.eventName}
</Link>
) : null}
{result.tournamentId ? (
<>
{logoUrl !== tournamentLogoUrl("default") ? (
<img
src={logoUrl}
alt=""
width={18}
height={18}
className="rounded-full"
/>
) : null}
<Link
to={tournamentBracketsPage({
tournamentId: result.tournamentId,
})}
data-testid="tournament-name-cell"
>
{result.eventName}
</Link>
</>
) : null}
</div>
</td>
<td>
<ul
className="u__results-players"
data-testid={`mates-cell-placement-${i}`}
>
{result.mates.map((player) => (
<li
key={player.name ? player.name : player.id}
className="flex items-center"
<ParticipationPill setResults={result.setResults} />
</td>
<td>
<div className="stack horizontal md items-center">
<SendouPopover
trigger={
<SendouButton
icon={<UsersIcon />}
size="miniscule"
variant="minimal"
data-testid="mates-button"
/>
}
>
<ul
className="u__results-players"
data-testid={`mates-cell-placement-${i}`}
>
{player.name ? (
player.name
) : (
// as any but we know it's a user since it doesn't have name
<Link
to={userPage(player as any)}
className="stack horizontal xs items-center"
{result.mates.map((player) => (
<li
key={player.name ? player.name : player.id}
className="flex items-center"
>
<Avatar user={player as any} size="xxs" />
{player.username}
</Link>
)}
</li>
))}
</ul>
{player.name ? (
player.name
) : (
// as any but we know it's a user since it doesn't have name
<Link
to={userPage(player as any)}
className="stack horizontal xs items-center"
>
<Avatar user={player as any} size="xxs" />
{player.username}
</Link>
)}
</li>
))}
</ul>
</SendouPopover>
{result.tournamentId ? (
<Link
to={tournamentTeamPage({
tournamentId: result.tournamentId,
tournamentTeamId: result.teamId,
})}
>
{result.teamName}
</Link>
) : (
result.teamName
)}
</div>
</td>
</tr>
);
@ -151,3 +189,32 @@ export function UserResultsTable({
</Table>
);
}
function ParticipationPill({
setResults,
}: {
setResults: UserResultsTableProps["results"][number]["setResults"];
}) {
if (!setResults) {
return null;
}
const playedCount = setResults.filter(Boolean).length;
const playedPercentage = Math.round((playedCount / setResults.length) * 100);
return (
<div className="u__results__pill__container">
<div className="u__results__pill__text">{playedPercentage}%</div>
<div className="u__results__pill">
{setResults.map((result, i) => (
<div
key={i}
className={clsx("u__results__pill-line", {
"u__results__pill-line__participating": result,
})}
/>
))}
</div>
</div>
);
}

View File

@ -1,16 +1,39 @@
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { notFoundIfFalsy, parseSafeSearchParams } from "~/utils/remix.server";
import { userResultsPageSearchParamsSchema } from "../user-page-schemas";
export type UserResultsLoaderData = SerializeFrom<typeof loader>;
// TODO: could further optimize by only loading highlighted results when needed
export const loader = async ({ params }: LoaderFunctionArgs) => {
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const parsedSearchParams = parseSafeSearchParams({
request,
schema: userResultsPageSearchParamsSchema,
});
const userId = notFoundIfFalsy(
await UserRepository.identifierToUserId(params.identifier!),
).id;
const hasHighlightedResults =
await UserRepository.hasHighlightedResultsByUserId(userId);
let showHighlightsOnly = parsedSearchParams.success
? !parsedSearchParams.data.all
: true;
if (!hasHighlightedResults) {
showHighlightsOnly = false;
}
const isChoosingHighlights = request.url.includes("/results/highlights");
if (isChoosingHighlights) {
showHighlightsOnly = false;
}
return {
results: await UserRepository.findResultsByUserId(userId),
results: await UserRepository.findResultsByUserId(userId, {
showHighlightsOnly,
}),
hasHighlightedResults,
};
};

View File

@ -1,16 +1,14 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import { seasonAllMMRByUserId } from "~/features/mmr/queries/seasonAllMMRByUserId.server";
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server";
import {
seasonMatchesByUserId,
seasonMatchesByUserIdPagesCount,
} from "~/features/sendouq/queries/seasonMatchesByUserId.server";
import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/seasonReportedWeaponsByUserId.server";
import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server";
import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server";
import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server";
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
@ -24,6 +22,7 @@ export type UserSeasonsPageLoaderData = NonNullable<
>;
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const loggedInUser = await getUser(request);
const { identifier } = userParamsSchema.parse(params);
const parsedSearchParams = seasonsSearchParamsSchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
@ -62,11 +61,24 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
skills: seasonAllMMRByUserId({ season, userId: user.id }),
tier,
isAccurateTiers,
matches: {
value: seasonMatchesByUserId({ season, userId: user.id, page }),
results: {
value: await QMatchRepository.seasonResultsByUserId({
season,
userId: user.id,
page,
}),
currentPage: page,
pages: seasonMatchesByUserIdPagesCount({ season, userId: user.id }),
pages: await QMatchRepository.seasonResultPagesByUserId({
season,
userId: user.id,
}),
},
canceled: loggedInUser?.roles.includes("STAFF")
? await QMatchRepository.seasonCanceledMatchesByUserId({
season,
userId: user.id,
})
: null,
season,
info: {
currentTab: info,

View File

@ -1,9 +1,8 @@
import { useLoaderData, useMatches } from "@remix-run/react";
import { useLoaderData, useMatches, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import { useUser } from "~/features/auth/core/user";
import { UserResultsTable } from "~/features/user-page/components/UserResultsTable";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import invariant from "~/utils/invariant";
import { userResultsEditHighlightsPage } from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
@ -20,18 +19,8 @@ export default function UserResultsPage() {
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const highlightedResults = data.results.filter(
(result) => result.isHighlight,
);
const hasHighlightedResults = highlightedResults.length > 0;
const [showAll, setShowAll] = useSearchParamState({
defaultValue: !hasHighlightedResults,
name: "all",
revive: (v) => (!hasHighlightedResults ? true : v === "true"),
});
const resultsToShow = showAll ? data.results : highlightedResults;
const [searchParams, setSearchParams] = useSearchParams();
const showAll = searchParams.get("all") === "true";
return (
<div className="stack lg">
@ -49,12 +38,18 @@ export default function UserResultsPage() {
</LinkButton>
) : null}
</div>
<UserResultsTable id="user-results-table" results={resultsToShow} />
{hasHighlightedResults ? (
<UserResultsTable id="user-results-table" results={data.results} />
{data.hasHighlightedResults ? (
<SendouButton
variant="minimal"
size="small"
onPress={() => setShowAll(!showAll)}
onPress={() =>
setSearchParams((params) => {
params.set("all", showAll ? "false" : "true");
return params;
})
}
>
{showAll
? t("results.button.showHighlights")

View File

@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import Chart from "~/components/Chart";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import {
SendouSelect,
@ -29,13 +30,17 @@ import {
TierImage,
WeaponImage,
} from "~/components/Image";
import { AlertIcon } from "~/components/icons/Alert";
import { Pagination } from "~/components/Pagination";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { TopTenPlayer } from "~/features/leaderboards/components/TopTenPlayer";
import { playerTopTenPlacement } from "~/features/leaderboards/leaderboards-utils";
import * as Seasons from "~/features/mmr/core/Seasons";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
import type {
SeasonGroupMatch,
SeasonTournamentResult,
} from "~/features/sendouq-match/QMatchRepository.server";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useWeaponUsage } from "~/hooks/swr";
import { useIsMounted } from "~/hooks/useIsMounted";
import { modesShort } from "~/modules/in-game-lists/modes";
@ -46,8 +51,13 @@ import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { cutToNDecimalPlaces, roundToNDecimalPlaces } from "~/utils/number";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { sendouQMatchPage, TIERS_PAGE, userSeasonsPage } from "~/utils/urls";
import {
sendouQMatchPage,
TIERS_PAGE,
tournamentTeamPage,
userSeasonsPage,
userSubmittedImage,
} from "~/utils/urls";
import {
loader,
type UserSeasonsPageLoaderData,
@ -72,7 +82,7 @@ export default function UserSeasonsPage() {
);
}
if (data.matches.value.length === 0) {
if (data.results.value.length === 0) {
return (
<div className="stack lg half-width">
<SeasonHeader
@ -87,7 +97,7 @@ export default function UserSeasonsPage() {
}
const tabLink = (tab: string) =>
`?info=${tab}&page=${data.matches.currentPage}&season=${data.season}`;
`?info=${tab}&page=${data.results.currentPage}&season=${data.season}`;
return (
<div className="stack lg half-width">
@ -157,7 +167,10 @@ export default function UserSeasonsPage() {
) : null}
</div>
</div>
<Matches matches={data.matches} seasonViewed={data.season} />
{data.canceled ? (
<CanceledMatchesDialog canceledMatches={data.canceled} />
) : null}
<Results results={data.results} seasonViewed={data.season} />
</div>
);
}
@ -650,12 +663,44 @@ function WeaponCircle({
);
}
function Matches({
/** Dialog for staff view all season's canceled matches per user */
function CanceledMatchesDialog({
canceledMatches,
}: {
canceledMatches: NonNullable<UserSeasonsPageLoaderData["canceled"]>;
}) {
return (
<SendouDialog
trigger={
<SendouButton
variant="minimal"
isDisabled={canceledMatches.length === 0}
>
Canceled Matches ({canceledMatches.length})
</SendouButton>
}
heading="Season's canceled matches for this user"
>
<div className="stack lg">
{canceledMatches.map((match) => (
<div key={match.id}>
<Link to={sendouQMatchPage(match.id)}>#{match.id}</Link>
<div>
{databaseTimestampToDate(match.createdAt).toLocaleString()}
</div>
</div>
))}
</div>
</SendouDialog>
);
}
function Results({
seasonViewed,
matches,
results,
}: {
seasonViewed: number;
matches: UserSeasonsPageLoaderData["matches"];
results: UserSeasonsPageLoaderData["results"];
}) {
const isMounted = useIsMounted();
const [, setSearchParams] = useSearchParams();
@ -666,11 +711,11 @@ function Matches({
};
React.useEffect(() => {
if (matches.currentPage === 1) return;
if (results.currentPage === 1) return;
ref.current?.scrollIntoView({
block: "center",
});
}, [matches.currentPage]);
}, [results.currentPage]);
let lastDayRendered: number | null = null;
return (
@ -678,13 +723,13 @@ function Matches({
<div ref={ref} />
<div className="stack lg">
<div className="stack">
{matches.value.map((match) => {
const day = databaseTimestampToDate(match.createdAt).getDate();
{results.value.map((result) => {
const day = databaseTimestampToDate(result.createdAt).getDate();
const shouldRenderDateHeader = day !== lastDayRendered;
lastDayRendered = day;
return (
<React.Fragment key={match.id}>
<React.Fragment key={result.id}>
<div
className={clsx(
"text-xs font-semi-bold text-theme-secondary",
@ -694,7 +739,7 @@ function Matches({
)}
>
{isMounted
? databaseTimestampToDate(match.createdAt).toLocaleString(
? databaseTimestampToDate(result.createdAt).toLocaleString(
"en",
{
weekday: "long",
@ -704,17 +749,21 @@ function Matches({
)
: "t"}
</div>
<Match match={match} />
{result.type === "GROUP_MATCH" ? (
<GroupMatchResult match={result.groupMatch} />
) : (
<TournamentResult result={result.tournamentResult} />
)}
</React.Fragment>
);
})}
</div>
{matches.pages > 1 ? (
{results.pages > 1 ? (
<Pagination
currentPage={matches.currentPage}
pagesCount={matches.pages}
nextPage={() => setPage(matches.currentPage + 1)}
previousPage={() => setPage(matches.currentPage - 1)}
currentPage={results.currentPage}
pagesCount={results.pages}
nextPage={() => setPage(results.currentPage + 1)}
previousPage={() => setPage(results.currentPage - 1)}
setPage={(page) => setPage(page)}
/>
) : null}
@ -723,28 +772,15 @@ function Matches({
);
}
function Match({
match,
}: {
match: UserSeasonsPageLoaderData["matches"]["value"][0];
}) {
const { t } = useTranslation(["user"]);
function GroupMatchResult({ match }: { match: SeasonGroupMatch }) {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const userId = layoutData.user.id;
const score = match.winnerGroupIds.reduce(
(acc, cur) => [
acc[0] + (cur === match.alphaGroupId ? 1 : 0),
acc[1] + (cur === match.bravoGroupId ? 1 : 0),
],
[0, 0],
);
// score when match has not yet been played or was canceled
const specialScoreMarking = () => {
if (score[0] + score[1] === 0) return match.isLocked ? "-" : " ";
if (match.score[0] + match.score[1] === 0) return " ";
return null;
};
@ -759,13 +795,13 @@ function Match({
<MatchMembersRow
key="alpha"
members={match.groupAlphaMembers}
score={specialScoreMarking() ?? score[0]}
score={specialScoreMarking() ?? match.score[0]}
reserveWeaponSpace={reserveWeaponSpace}
/>,
<MatchMembersRow
key="bravo"
members={match.groupBravoMembers}
score={specialScoreMarking() ?? score[1]}
score={specialScoreMarking() ?? match.score[1]}
reserveWeaponSpace={reserveWeaponSpace}
/>,
]
@ -773,13 +809,13 @@ function Match({
<MatchMembersRow
key="bravo"
members={match.groupBravoMembers}
score={specialScoreMarking() ?? score[1]}
score={specialScoreMarking() ?? match.score[1]}
reserveWeaponSpace={reserveWeaponSpace}
/>,
<MatchMembersRow
key="alpha"
members={match.groupAlphaMembers}
score={specialScoreMarking() ?? score[0]}
score={specialScoreMarking() ?? match.score[0]}
reserveWeaponSpace={reserveWeaponSpace}
/>,
];
@ -789,8 +825,7 @@ function Match({
<Link
to={sendouQMatchPage(match.id)}
className={clsx("u__season__match", {
"u__season__match__with-sub-section ":
match.spDiff || !match.isLocked,
"u__season__match__with-sub-section ": match.spDiff,
})}
>
{rows}
@ -805,10 +840,49 @@ function Match({
{Math.abs(roundToNDecimalPlaces(match.spDiff))}SP
</div>
) : null}
{!match.isLocked ? (
</div>
);
}
function TournamentResult({ result }: { result: SeasonTournamentResult }) {
const logoUrl = result.logoUrl
? userSubmittedImage(result.logoUrl)
: HACKY_resolvePicture({ name: result.tournamentName });
return (
<div data-testid="seasons-tournament-result">
<Link
to={tournamentTeamPage(result)}
className={clsx("u__season__match", {
"u__season__match__with-sub-section ": result.spDiff,
})}
>
<div className="stack font-bold items-center text-lg text-center">
<img
src={logoUrl}
width={36}
height={36}
alt=""
className="rounded-full"
/>
{result.tournamentName}
</div>
<ul className="u__season__match__set-results">
{result.setResults.filter(Boolean).map((result, i) => (
<li key={i} data-is-win={String(result === "W")}>
{result}
</li>
))}
</ul>
</Link>
{result.spDiff ? (
<div className="u__season__match__sub-section">
<AlertIcon className="u__season__match__sub-section__icon" />
{t("user:seasons.matchBeingProcessed")}
{result.spDiff > 0 ? (
<span className="text-success"></span>
) : (
<span className="text-warning"></span>
)}
{Math.abs(roundToNDecimalPlaces(result.spDiff))}SP
</div>
) : null}
</div>
@ -821,7 +895,7 @@ function MatchMembersRow({
reserveWeaponSpace,
}: {
score: React.ReactNode;
members: UserSeasonsPageLoaderData["matches"]["value"][0]["groupAlphaMembers"];
members: SeasonGroupMatch["groupAlphaMembers"];
reserveWeaponSpace: boolean;
}) {
return (

View File

@ -75,44 +75,63 @@ export default function UserPageLayout() {
return (
<Main bigger={location.pathname.includes("results")}>
<SubNav>
<SubNavLink to={userPage(data.user)}>
<SubNavLink to={userPage(data.user)} data-testid="user-profile-tab">
{t("common:header.profile")}
</SubNavLink>
<SubNavLink to={userSeasonsPage({ user: data.user })}>
<SubNavLink
to={userSeasonsPage({ user: data.user })}
data-testid="user-seasons-tab"
>
{t("user:seasons")}
</SubNavLink>
{isOwnPage && (
<SubNavLink to={userEditProfilePage(data.user)} prefetch="intent">
{isOwnPage ? (
<SubNavLink
to={userEditProfilePage(data.user)}
prefetch="intent"
data-testid="user-edit-tab"
>
{t("common:actions.edit")}
</SubNavLink>
)}
{allResultsCount > 0 && (
<SubNavLink to={userResultsPage(data.user)}>
) : null}
{allResultsCount > 0 ? (
<SubNavLink
to={userResultsPage(data.user)}
data-testid="user-results-tab"
>
{t("common:results")} ({allResultsCount})
</SubNavLink>
)}
{(data.user.buildsCount > 0 || isOwnPage) && (
) : null}
{data.user.buildsCount > 0 || isOwnPage ? (
<SubNavLink
to={userBuildsPage(data.user)}
prefetch="intent"
data-testid="builds-tab"
data-testid="user-builds-tab"
>
{t("common:pages.builds")} ({data.user.buildsCount})
</SubNavLink>
)}
{(data.user.vodsCount > 0 || isOwnPage) && (
<SubNavLink to={userVodsPage(data.user)}>
) : null}
{data.user.vodsCount > 0 || isOwnPage ? (
<SubNavLink to={userVodsPage(data.user)} data-testid="user-vods-tab">
{t("common:pages.vods")} ({data.user.vodsCount})
</SubNavLink>
)}
{(data.user.artCount > 0 || isOwnPage) && (
<SubNavLink to={userArtPage(data.user)} end={false}>
) : null}
{data.user.artCount > 0 || isOwnPage ? (
<SubNavLink
to={userArtPage(data.user)}
end={false}
data-testid="user-art-tab"
>
{t("common:pages.art")} ({data.user.artCount})
</SubNavLink>
)}
{isStaff && (
<SubNavLink to={userAdminPage(data.user)}>Admin</SubNavLink>
)}
) : null}
{isStaff ? (
<SubNavLink
to={userAdminPage(data.user)}
data-testid="user-admin-tab"
>
Admin
</SubNavLink>
) : null}
</SubNav>
<Outlet />
</Main>

View File

@ -158,3 +158,7 @@ export const adminTabActionSchema = z.union([
addModNoteSchema,
deleteModNoteSchema,
]);
export const userResultsPageSearchParamsSchema = z.object({
all: z.stringbool(),
});

View File

@ -130,11 +130,12 @@ export default [
route("/support", "features/info/routes/support.tsx"),
route("/t", "features/team/routes/t.tsx"),
...prefix("/t/:customUrl", [
index("features/team/routes/t.$customUrl.tsx"),
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx", [
index("features/team/routes/t.$customUrl.index.tsx"),
route("edit", "features/team/routes/t.$customUrl.edit.tsx"),
route("roster", "features/team/routes/t.$customUrl.roster.tsx"),
route("join", "features/team/routes/t.$customUrl.join.tsx"),
route("results", "features/team/routes/t.$customUrl.results.tsx"),
]),
...prefix("/vods", [

View File

@ -145,6 +145,36 @@
list-style: none;
}
.u__results__pill__container {
width: 60px;
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.u__results__pill__text {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-align: center;
}
.u__results__pill {
display: flex;
gap: var(--s-1);
height: 10px;
}
.u__results__pill-line {
width: 100%;
height: 100%;
background-color: var(--bg-lightest);
border-radius: var(--rounded);
}
.u__results__pill-line__participating {
background-color: var(--theme);
}
.u-search__container {
display: flex;
flex-direction: column;
@ -344,11 +374,6 @@
padding-block: var(--s-0-5);
}
.u__season__match__sub-section__icon {
width: 18px;
color: var(--theme-warning);
}
.u__season__match:hover {
background-color: var(--theme-transparent);
}
@ -374,6 +399,32 @@
margin-inline: auto;
}
.u__season__match__set-results {
list-style: none;
display: flex;
gap: var(--s-2);
flex-direction: row;
margin: 0 auto;
padding: 0;
}
.u__season__match__set-results li {
width: 28px;
height: 28px;
display: grid;
place-items: center;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
border: 3px solid var(--theme);
border-radius: var(--rounded-sm);
color: var(--text);
border-color: var(--theme-error);
}
.u__season__match__set-results li[data-is-win="true"] {
border-color: var(--theme-success);
}
.u__season__info-container {
background-color: var(--bg-lighter);
padding: var(--s-2-5) var(--s-2);

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { diff } from "./arrays";
import { diff, mostPopularArrayElement } from "./arrays";
describe("diff", () => {
it("should return elements in arr2 but not in arr1", () => {
@ -37,3 +37,35 @@ describe("diff", () => {
expect(result).toEqual([]);
});
});
describe("mostPopularArrayElement", () => {
it("should return the most frequent element in an array of numbers", () => {
const arr = [1, 2, 2, 3, 3, 3, 4];
const result = mostPopularArrayElement(arr);
expect(result).toBe(3);
});
it("should return the most frequent element in an array of strings", () => {
const arr = ["a", "b", "b", "c", "a", "b"];
const result = mostPopularArrayElement(arr);
expect(result).toBe("b");
});
it("should return the first most frequent element if there is a tie", () => {
const arr = [1, 2, 2, 1];
const result = mostPopularArrayElement(arr);
expect(result).toBe(1);
});
it("should return null for an empty array", () => {
const arr: number[] = [];
const result = mostPopularArrayElement(arr);
expect(result).toBeNull();
});
it("should return the element itself for a single-element array", () => {
const arr = ["only"];
const result = mostPopularArrayElement(arr);
expect(result).toBe("only");
});
});

View File

@ -47,11 +47,6 @@ export function normalizeFormFieldArray(
return value == null ? [] : typeof value === "string" ? [value] : value;
}
/** Can be used as a strongly typed array filter */
export function isDefined<T>(value: T | undefined | null): value is T {
return value !== null && value !== undefined;
}
export function nullFilledArray(size: number): null[] {
return new Array(size).fill(null);
}
@ -98,3 +93,20 @@ export function diff<T extends string | number>(arr1: T[], arr2: T[]): T[] {
return result;
}
export function mostPopularArrayElement<T>(arr: T[]): T | null {
if (arr.length === 0) return null;
const counts = countElements(arr);
let mostPopularElement: T | null = null;
let maxCount = 0;
for (const [element, count] of counts) {
if (count > maxCount) {
maxCount = count;
mostPopularElement = element;
}
}
return mostPopularElement;
}

Binary file not shown.

View File

@ -81,7 +81,9 @@ test.describe("Builds", () => {
await page.getByTestId("submit-button").click();
await expect(page.getByTestId("builds-tab")).toContainText("Builds (50)");
await expect(page.getByTestId("user-builds-tab")).toContainText(
"Builds (50)",
);
await expect(page.getByTestId("build-card").first()).toContainText(
"Private",
);
@ -91,7 +93,9 @@ test.describe("Builds", () => {
page,
url: userBuildsPage({ discordId: ADMIN_DISCORD_ID }),
});
await expect(page.getByTestId("builds-tab")).toContainText("Builds (49)");
await expect(page.getByTestId("user-builds-tab")).toContainText(
"Builds (49)",
);
await expect(page.getByTestId("build-card").first()).not.toContainText(
"Private",
);

View File

@ -339,6 +339,8 @@ test.describe("Tournament bracket", () => {
test("completes and finalizes a small tournament (RR->SE w/ underground bracket)", async ({
page,
}) => {
test.slow();
const tournamentId = 3;
await seed(page);
@ -439,15 +441,20 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("result-team-name").first().click();
await page.getByTestId("team-member-name").first().click();
await expect(page).toHaveURL(/\/u\//);
await page.getByTestId("user-seasons-tab").click();
await expect(page.getByTestId("seasons-tournament-result")).toBeVisible();
await page.getByText("Results").click();
await page.getByTestId("user-results-tab").click();
await expect(
page.getByTestId("tournament-name-cell").first(),
).toContainText("Paddling Pool 253");
await page.getByTestId("mates-button").first().click();
await expect(
page.locator('[data-testid="mates-cell-placement-0"] li'),
).toHaveCount(3);
// if more assertions added below we need to close the popover first (data-testid="underlay")
});
test("changes SOS format and progresses with it & adds a member to another team", async ({

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Lav bane-liste",
"maps.halfSz": "50% DD",

View File

@ -26,8 +26,10 @@
"results.placing": "Placering",
"results.team": "Hold",
"results.tournament": "Turnering",
"results.participation": "",
"results.date": "Dato",
"results.mates": "Holdkammerater",
"results.subs": "",
"results.highlights": "Højdepunkter",
"results.highlights.choose": "Vælg højdepunkter",
"results.highlights.explanation": "Vælg de resultater, som du vil fremhæve",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "Der er endnu ikke blevet indrapporteret nogle våben",
"seasons.clickARow": "Tryk på en række for at se våbenbrugsstatistikker.",
"seasons.loading": "indlæser...",
"seasons.matchBeingProcessed": "Denne kamp er endnu ikke blevet færdigbehandlet",
"builds.sorting.changeButton": "Ændr sortering",
"builds.sorting.header": "Ændr sæt-sortering",
"builds.sorting.backToDefaults": "Nulstil visning",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Arenen-Liste erstellen",
"maps.halfSz": "50% Herrschaft",

View File

@ -26,8 +26,10 @@
"results.placing": "Platzierung",
"results.team": "Team",
"results.tournament": "Turnier",
"results.participation": "",
"results.date": "Datum",
"results.mates": "Mitspieler",
"results.subs": "",
"results.highlights": "Highlights",
"results.highlights.choose": "Highlights wählen",
"results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.matchBeingProcessed": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "Accept",
"actions.next": "Next",
"actions.previous": "Previous",
"actions.back": "Back",
"noResults": "No results",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",

View File

@ -26,8 +26,10 @@
"results.placing": "Placing",
"results.team": "Team",
"results.tournament": "Tournament",
"results.participation": "Participation",
"results.date": "Date",
"results.mates": "Mates",
"results.subs": "Subs",
"results.highlights": "Highlights",
"results.highlights.choose": "Choose highlights",
"results.highlights.explanation": "Select the results you want to highlight",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "No reported weapons yet",
"seasons.clickARow": "Click a row to see weapon usage stats",
"seasons.loading": "Loading...",
"seasons.matchBeingProcessed": "This match has not been processed yet",
"builds.sorting.changeButton": "Change sorting",
"builds.sorting.header": "Change build sorting",
"builds.sorting.backToDefaults": "Back to defaults",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Crear lista de mapas",
"maps.halfSz": "50% Pintazonas",

View File

@ -26,8 +26,10 @@
"results.placing": "Lugar",
"results.team": "Equipo",
"results.tournament": "Torneo",
"results.participation": "",
"results.date": "Fecha",
"results.mates": "Compañeros",
"results.subs": "",
"results.highlights": "Resaltos",
"results.highlights.choose": "Elegir resaltos",
"results.highlights.explanation": "Elige los resultados que quieres resaltar",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "No se han informado armas",
"seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas",
"seasons.loading": "Cargando...",
"seasons.matchBeingProcessed": "No se ha procesado este partido",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Crear lista de escenarios",
"maps.halfSz": "50% Pintazonas",

View File

@ -26,8 +26,10 @@
"results.placing": "Lugar",
"results.team": "Equipo",
"results.tournament": "Torneo",
"results.participation": "",
"results.date": "Fecha",
"results.mates": "Compañeros",
"results.subs": "",
"results.highlights": "Resaltos",
"results.highlights.choose": "Elegir resaltos",
"results.highlights.explanation": "Elige los resultados que quieres resaltar",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "No se han informado armas",
"seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas",
"seasons.loading": "Cargando...",
"seasons.matchBeingProcessed": "No se ha procesado este partido",
"builds.sorting.changeButton": "Cambiar ordenación",
"builds.sorting.header": "Cambiar ordenación de builds",
"builds.sorting.backToDefaults": "Volver a los valores predeterminados",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ",

View File

@ -26,8 +26,10 @@
"results.placing": "Placement",
"results.team": "Équipe",
"results.tournament": "Tournoi",
"results.participation": "",
"results.date": "Date",
"results.mates": "Équipiers",
"results.subs": "",
"results.highlights": "Résultats notables",
"results.highlights.choose": "Choisir vos résultats notables",
"results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.matchBeingProcessed": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "Accepter",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "Aucun résultats",
"maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ",

View File

@ -26,8 +26,10 @@
"results.placing": "Placement",
"results.team": "Équipe",
"results.tournament": "Tournoi",
"results.participation": "",
"results.date": "Date",
"results.mates": "Équipiers",
"results.subs": "",
"results.highlights": "Résultats notables",
"results.highlights.choose": "Choisir vos résultats notables",
"results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "Aucune arme a été reporté",
"seasons.clickARow": "Cliquez sur une ligne pour voir les statistiques d'utilisation des armes",
"seasons.loading": "Chargement...",
"seasons.matchBeingProcessed": "Le match n'a pas encore été terminé",
"builds.sorting.changeButton": "Changer le tri",
"builds.sorting.header": "Modifier le tri des builds",
"builds.sorting.backToDefaults": "Revenir par défaut",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "יצירת רשימת מפות",
"maps.halfSz": "50% SZ",

View File

@ -26,8 +26,10 @@
"results.placing": "מיקום",
"results.team": "צוות",
"results.tournament": "טורניר",
"results.participation": "",
"results.date": "תאריך",
"results.mates": "חברי צוות",
"results.subs": "",
"results.highlights": "נקודות שיא",
"results.highlights.choose": "בחרו נקודות שיא",
"results.highlights.explanation": "בחרו את התוצאות שאתם רוצים להדגיש",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.matchBeingProcessed": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Crea lista scenari",
"maps.halfSz": "50% ZS",

View File

@ -26,8 +26,10 @@
"results.placing": "Risultato",
"results.team": "Team",
"results.tournament": "Torneo",
"results.participation": "",
"results.date": "Data",
"results.mates": "Compagni",
"results.subs": "",
"results.highlights": "Highlight",
"results.highlights.choose": "Scegli i tuoi highlight",
"results.highlights.explanation": "Scegli il risultato che vuoi mettere come highlight",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "Nessun'arma riportata",
"seasons.clickARow": "Clicca una riga per visualizzare le statistiche d'uso delle armi",
"seasons.loading": "Caricamento...",
"seasons.matchBeingProcessed": "Questo match non è ancora stato processato",
"builds.sorting.changeButton": "Cambia ordinamento",
"builds.sorting.header": "Cambia ordinamento build",
"builds.sorting.backToDefaults": "Torna al default",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "ステージ一覧を作る",
"maps.halfSz": "ガチエリア (2ヶ所)",

View File

@ -26,8 +26,10 @@
"results.placing": "順位",
"results.team": "チーム",
"results.tournament": "トーナメント",
"results.participation": "",
"results.date": "日",
"results.mates": "フレンド",
"results.subs": "",
"results.highlights": "主な戦績",
"results.highlights.choose": "戦績を選ぶ",
"results.highlights.explanation": "戦績として選択したい結果を選ぶ",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "報告された武器がありません",
"seasons.clickARow": "武器の使用統計を見るには行を選択してください",
"seasons.loading": "読み込み中...",
"seasons.matchBeingProcessed": "このマッチはまだ処理されていません。",
"builds.sorting.changeButton": "並べ替え変更",
"builds.sorting.header": "ギアの並べ替えを変更",
"builds.sorting.backToDefaults": "デフォルトに戻す",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "맵 목록 생성",
"maps.halfSz": "에어리어 50%",

View File

@ -26,8 +26,10 @@
"results.placing": "순위",
"results.team": "팀",
"results.tournament": "대회",
"results.participation": "",
"results.date": "날짜",
"results.mates": "동료",
"results.subs": "",
"results.highlights": "",
"results.highlights.choose": "",
"results.highlights.explanation": "",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.matchBeingProcessed": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Maak levellijst",
"maps.halfSz": "50% SZ",

View File

@ -26,8 +26,10 @@
"results.placing": "Plaatsing",
"results.team": "Team",
"results.tournament": "Toernooi",
"results.participation": "",
"results.date": "Datum",
"results.mates": "Teamleden",
"results.subs": "",
"results.highlights": "",
"results.highlights.choose": "",
"results.highlights.explanation": "",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.matchBeingProcessed": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Stwórz liste map",
"maps.halfSz": "50% SZ",

View File

@ -26,8 +26,10 @@
"results.placing": "Placing",
"results.team": "Drużyna",
"results.tournament": "Turniej",
"results.participation": "",
"results.date": "Data",
"results.mates": "Koledzy",
"results.subs": "",
"results.highlights": "Wyróżnienia",
"results.highlights.choose": "Wybierz wyróżnienia",
"results.highlights.explanation": "Wybierz wyniki, które chcesz wyróżnić",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "",
"seasons.clickARow": "",
"seasons.loading": "",
"seasons.matchBeingProcessed": "",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "Criar lista de mapas",
"maps.halfSz": "50% Zones",

View File

@ -26,8 +26,10 @@
"results.placing": "Classificação",
"results.team": "Time",
"results.tournament": "Torneio",
"results.participation": "",
"results.date": "Data",
"results.mates": "Parceiros(as)",
"results.subs": "",
"results.highlights": "Destaques",
"results.highlights.choose": "Escolher Destaques",
"results.highlights.explanation": "Escolha os resultados que você quer destacar",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "As armas ainda não foram declaradas",
"seasons.clickARow": "Clique em uma fileira pra ver as estatísticas de uso da arma",
"seasons.loading": "Carregando...",
"seasons.matchBeingProcessed": "Essa partida não foi processada ainda",
"builds.sorting.changeButton": "",
"builds.sorting.header": "",
"builds.sorting.backToDefaults": "",

View File

@ -122,6 +122,7 @@
"actions.accept": "Подтвердить",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "Нет результатов",
"maps.createMapList": "Создать список карт",
"maps.halfSz": "50% Зон",

View File

@ -26,8 +26,10 @@
"results.placing": "Место",
"results.team": "Команда",
"results.tournament": "Турнир",
"results.participation": "",
"results.date": "Дата",
"results.mates": "Напарники",
"results.subs": "",
"results.highlights": "Избранное",
"results.highlights.choose": "Выберите избранное",
"results.highlights.explanation": "Выберите ваш избранный результат",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "Нет записанного оружия",
"seasons.clickARow": "Нажмите на ряд, чтобы посмотреть на статистику использованного оружия.",
"seasons.loading": "Загрузка...",
"seasons.matchBeingProcessed": "Данный матч ещё не был обработан",
"builds.sorting.changeButton": "Изменить сортировку",
"builds.sorting.header": "Изменить сортировку сборок",
"builds.sorting.backToDefaults": "По умолчанию",

View File

@ -122,6 +122,7 @@
"actions.accept": "",
"actions.next": "",
"actions.previous": "",
"actions.back": "",
"noResults": "",
"maps.createMapList": "创建地图列表",
"maps.halfSz": "50%为真格区域",

View File

@ -26,8 +26,10 @@
"results.placing": "排名",
"results.team": "队伍",
"results.tournament": "比赛",
"results.participation": "",
"results.date": "日期",
"results.mates": "队友",
"results.subs": "",
"results.highlights": "高光成绩",
"results.highlights.choose": "选择高光成绩",
"results.highlights.explanation": "选择您想强调的高光成绩",
@ -62,7 +64,6 @@
"seasons.noReportedWeapons": "没有武器信息",
"seasons.clickARow": "点击一行来查看武器使用数据",
"seasons.loading": "加载中...",
"seasons.matchBeingProcessed": "这场对决还未开始",
"builds.sorting.changeButton": "更改顺序",
"builds.sorting.header": "更改配装顺序",
"builds.sorting.backToDefaults": "回到默认值",

View File

@ -0,0 +1,15 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentResult" add "setResults" text not null default '[]'`,
).run();
db.prepare(
/* sql */ `alter table "TournamentResult" add "spDiff" real`,
).run();
db.prepare(/* sql */ `alter table "Skill" add "createdAt" integer`).run();
db.prepare(
/*sql*/ `create index tournament_team_team_id on "TournamentTeam"("teamId")`,
).run();
})();
}

View File

@ -22,7 +22,7 @@ async function main() {
const skills = calculateIndividualPlayerSkills({
queryCurrentUserRating(userId) {
return ratings.get(userId) ?? rating();
return { rating: ratings.get(userId) ?? rating(), matchesCount: 0 };
},
results,
});

View File

@ -0,0 +1,190 @@
import "dotenv/config";
import { sql } from "kysely";
import { db } from "../app/db/sql";
import {
setResults,
type TournamentSummary,
} from "../app/features/tournament-bracket/core/summarizer.server";
import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server";
import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server";
import invariant from "../app/utils/invariant";
import { logger } from "../app/utils/logger";
async function main() {
logger.info(
"Starting to fix tournamentTeamId in TournamentMatchGameResultParticipant",
);
await tournamentTeamIdsToTournamentMatchGameResultParticipantTable();
logger.info("Fixed tournamentTeamId in TournamentMatchGameResultParticipant");
const result: Array<
{ tournamentId: number } & Pick<TournamentSummary, "setResults">
> = [];
let count = 0;
for await (const tournament of tournaments()) {
count++;
const results = allMatchResultsByTournamentId(tournament.ctx.id);
invariant(results.length > 0, "No results found");
result.push({
tournamentId: tournament.ctx.id,
setResults: setResults({ results, teams: tournament.ctx.teams }),
});
if (count % 100 === 0) {
logger.info(`Processed ${count} tournaments`);
}
}
await db.transaction().execute(async (trx) => {
await trx
.updateTable("TournamentResult")
.set({
setResults: JSON.stringify([]),
})
.execute();
for (const { tournamentId, setResults } of result) {
for (const [userId, setResult] of setResults.entries()) {
await trx
.updateTable("TournamentResult")
.set({
setResults: JSON.stringify(setResult),
})
.where("tournamentId", "=", tournamentId)
.where("userId", "=", userId)
.execute();
}
}
});
logger.info(`Done. Total of ${result.length} results inserted.`);
await wipeEmptyResults();
}
async function* tournaments() {
const maxId = await db
.selectFrom("Tournament")
.select(({ fn }) => fn.max("id").as("maxId"))
.executeTakeFirstOrThrow()
.then((row) => row.maxId);
for (let tournamentId = 1; tournamentId <= maxId; tournamentId++) {
if (tournamentId === 1483) {
// broken one
continue;
}
try {
const tournament = await tournamentFromDB({
tournamentId,
user: undefined,
});
if (!tournament.ctx.isFinalized) {
continue;
}
yield tournament;
} catch (thrown) {
if (thrown instanceof Response) continue;
throw thrown;
}
}
}
// https://github.com/sendou-ink/sendou.ink/commit/96781122e2c5f9cd90564c9b57a45b74557fc400
async function tournamentTeamIdsToTournamentMatchGameResultParticipantTable() {
await db
.updateTable("TournamentMatchGameResultParticipant")
.set((eb) => ({
tournamentTeamId: eb
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
// exclude teams that have not checked in
.innerJoin(
"TournamentTeamCheckIn",
"TournamentTeamCheckIn.tournamentTeamId",
"TournamentTeam.id",
)
.select("TournamentTeam.id")
.whereRef(
"TournamentTeamMember.userId",
"=",
"TournamentMatchGameResultParticipant.userId",
)
.whereRef(
"TournamentTeam.tournamentId",
"=",
eb
.selectFrom("TournamentMatchGameResult")
.innerJoin(
"TournamentMatch",
"TournamentMatchGameResult.matchId",
"TournamentMatch.id",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin(
"Tournament",
"Tournament.id",
"TournamentStage.tournamentId",
)
.whereRef(
"TournamentMatchGameResult.id",
"=",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.select("Tournament.id")
.limit(1),
),
}))
.where("TournamentMatchGameResultParticipant.tournamentTeamId", "is", null)
.execute();
// manual fixes, not sure why these are needed
await db
.updateTable("TournamentMatchGameResultParticipant")
.set({
tournamentTeamId: 13077,
})
.where("userId", "=", 44085)
.where("tournamentTeamId", "is", null)
.execute();
await db
.updateTable("TournamentMatchGameResultParticipant")
.set({
tournamentTeamId: 14589,
})
.where("userId", "=", 10585)
.where("tournamentTeamId", "is", null)
.execute();
}
async function wipeEmptyResults() {
logger.info("Wiping empty results from TournamentResult table...");
const { numDeletedRows } = await db
.deleteFrom("TournamentResult")
.where(sql<boolean>`instr(setResults, 'W') = 0`)
.where(sql<boolean>`instr(setResults, 'L') = 0`)
.executeTakeFirst();
logger.info(
`Wiped ${numDeletedRows} empty results from TournamentResult table.`,
);
}
main().catch((err) => {
logger.error("Error in calc-tournament-summary-result-arrays.ts", err);
process.exit(1);
});