mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Tournament results participation, seasons page with tournaments & team results page (#2424)
This commit is contained in:
parent
735b506f56
commit
3d2ede6f3d
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
29
app/features/team/components/TeamGoBackButton.tsx
Normal file
29
app/features/team/components/TeamGoBackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
app/features/team/components/TeamResultsTable.module.css
Normal file
7
app/features/team/components/TeamResultsTable.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
gap: var(--s-3);
|
||||
list-style: none;
|
||||
}
|
||||
122
app/features/team/components/TeamResultsTable.tsx
Normal file
122
app/features/team/components/TeamResultsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/features/team/loaders/t.$customUrl.results.server.ts
Normal file
19
app/features/team/loaders/t.$customUrl.results.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
251
app/features/team/routes/t.$customUrl.index.tsx
Normal file
251
app/features/team/routes/t.$customUrl.index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/features/team/routes/t.$customUrl.results.tsx
Normal file
17
app/features/team/routes/t.$customUrl.results.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
76
app/features/team/team-utils.test.ts
Normal file
76
app/features/team/team-utils.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@
|
|||
justify-content: space-between;
|
||||
width: min(100%, 48rem);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team__results__placements {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -158,3 +158,7 @@ export const adminTabActionSchema = z.union([
|
|||
addModNoteSchema,
|
||||
deleteModNoteSchema,
|
||||
]);
|
||||
|
||||
export const userResultsPageSearchParamsSchema = z.object({
|
||||
all: z.stringbool(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Lav bane-liste",
|
||||
"maps.halfSz": "50% DD",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Arenen-Liste erstellen",
|
||||
"maps.halfSz": "50% Herrschaft",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Crear lista de mapas",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Crear lista de escenarios",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "יצירת רשימת מפות",
|
||||
"maps.halfSz": "50% SZ",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Crea lista scenari",
|
||||
"maps.halfSz": "50% ZS",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "ステージ一覧を作る",
|
||||
"maps.halfSz": "ガチエリア (2ヶ所)",
|
||||
|
|
|
|||
|
|
@ -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": "デフォルトに戻す",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "맵 목록 생성",
|
||||
"maps.halfSz": "에어리어 50%",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Maak levellijst",
|
||||
"maps.halfSz": "50% SZ",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Stwórz liste map",
|
||||
"maps.halfSz": "50% SZ",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Criar lista de mapas",
|
||||
"maps.halfSz": "50% Zones",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "Подтвердить",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "Нет результатов",
|
||||
"maps.createMapList": "Создать список карт",
|
||||
"maps.halfSz": "50% Зон",
|
||||
|
|
|
|||
|
|
@ -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": "По умолчанию",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"actions.accept": "",
|
||||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "创建地图列表",
|
||||
"maps.halfSz": "50%为真格区域",
|
||||
|
|
|
|||
|
|
@ -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": "回到默认值",
|
||||
|
|
|
|||
15
migrations/093-ranked-tournament-results.js
Normal file
15
migrations/093-ranked-tournament-results.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
190
scripts/calc-tournament-summary-result-arrays.ts
Normal file
190
scripts/calc-tournament-summary-result-arrays.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user