diff --git a/app/db/tables.ts b/app/db/tables.ts index 85644dbed..8c6934f42 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; + isHighlight: Generated; 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; + /** The SP change in total after the finalization of a ranked tournament. */ + spDiff: number | null; userId: number; } diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 119567828..c7f10752b 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -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 diff --git a/app/features/sendouq-match/QMatchRepository.server.ts b/app/features/sendouq-match/QMatchRepository.server.ts index 29aee483f..75e0b7250 100644 --- a/app/features/sendouq-match/QMatchRepository.server.ts +++ b/app/features/sendouq-match/QMatchRepository.server.ts @@ -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 { + 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, + 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) => { + const groupMembersSubQuery = ( + eb: ExpressionBuilder, + 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>>, + { type: "GROUP_MATCH" } +>["groupMatch"]; + +export type SeasonTournamentResult = Extract< + Unpacked>>, + { 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(); +} diff --git a/app/features/sendouq-match/queries/addSkills.server.ts b/app/features/sendouq-match/queries/addSkills.server.ts index e54843461..cb08d912c 100644 --- a/app/features/sendouq-match/queries/addSkills.server.ts +++ b/app/features/sendouq-match/queries/addSkills.server.ts @@ -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"]; diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index e2e91850b..01c8d8637 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -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> +>; + +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> +>; + +/** + * 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, diff --git a/app/features/team/actions/t.$customUrl.server.ts b/app/features/team/actions/t.$customUrl.index.server.ts similarity index 100% rename from app/features/team/actions/t.$customUrl.server.ts rename to app/features/team/actions/t.$customUrl.index.server.ts diff --git a/app/features/team/components/TeamGoBackButton.tsx b/app/features/team/components/TeamGoBackButton.tsx new file mode 100644 index 000000000..843100f19 --- /dev/null +++ b/app/features/team/components/TeamGoBackButton.tsx @@ -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 ( +
+ } + variant="outlined" + size="small" + className="mr-auto" + > + {t("common:actions.back")} + +
+ ); +} diff --git a/app/features/team/components/TeamResultsTable.module.css b/app/features/team/components/TeamResultsTable.module.css new file mode 100644 index 000000000..411c4bf36 --- /dev/null +++ b/app/features/team/components/TeamResultsTable.module.css @@ -0,0 +1,7 @@ +.players { + display: flex; + flex-direction: column; + padding: 0; + gap: var(--s-3); + list-style: none; +} diff --git a/app/features/team/components/TeamResultsTable.tsx b/app/features/team/components/TeamResultsTable.tsx new file mode 100644 index 000000000..862ee4632 --- /dev/null +++ b/app/features/team/components/TeamResultsTable.tsx @@ -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 ( + + + + + + + + + + + {results.map((result) => { + const logoUrl = result.logoUrl + ? userSubmittedImage(result.logoUrl) + : HACKY_resolvePicture({ name: result.tournamentName }); + + return ( + + + + + + + ); + })} + +
{t("results.placing")}{t("results.date")}{t("results.tournament")}{t("results.subs")}
+
+ {" "} +
+ / {result.participantCount} +
+
+
+ {databaseTimestampToDate(result.startTime).toLocaleDateString( + i18n.language, + { + day: "numeric", + month: "short", + year: "numeric", + }, + )} + +
+ {logoUrl !== tournamentLogoUrl("default") ? ( + + ) : null} + + {result.tournamentName} + +
+
+ {result.subs.length > 0 ? ( +
+ } + size="small" + variant="minimal" + > + {result.subs.length} + + } + > +
    + {result.subs.map((player) => ( +
  • + + + {player.username} + +
  • + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/app/features/team/loaders/t.$customUrl.results.server.ts b/app/features/team/loaders/t.$customUrl.results.server.ts new file mode 100644 index 000000000..c8285c41c --- /dev/null +++ b/app/features/team/loaders/t.$customUrl.results.server.ts @@ -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; + +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, + }; +}; diff --git a/app/features/team/loaders/t.$customUrl.roster.server.ts b/app/features/team/loaders/t.$customUrl.roster.server.ts index f2525597a..ab93f4fd0 100644 --- a/app/features/team/loaders/t.$customUrl.roster.server.ts +++ b/app/features/team/loaders/t.$customUrl.roster.server.ts @@ -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); diff --git a/app/features/team/loaders/t.$customUrl.server.ts b/app/features/team/loaders/t.$customUrl.server.ts index 4fdcce116..093c69a54 100644 --- a/app/features/team/loaders/t.$customUrl.server.ts +++ b/app/features/team/loaders/t.$customUrl.server.ts @@ -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; + 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, + }, + ], + }; +} diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index becd12598..b777d7d6a 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -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 | 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(); return ( -
- {isTeamOwner({ team, user }) ? ( - - + +
+ {isTeamOwner({ team, user }) ? ( + - {t("team:actionButtons.deleteTeam")} - - - ) : null} -
- - - {canAddCustomizedColors(team) ? ( - + + {t("team:actionButtons.deleteTeam")} + + ) : null} - - - - - {t("common:actions.submit")} - - - +
+ + + {canAddCustomizedColors(team) ? ( + + ) : null} + + + + + {t("common:actions.submit")} + + + +
); } diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx new file mode 100644 index 000000000..925f8b4be --- /dev/null +++ b/app/features/team/routes/t.$customUrl.index.tsx @@ -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 ( +
+ + {layoutData.results ? ( + + ) : null} + {layoutData.team.bio ? ( +
{layoutData.team.bio}
+ ) : null} +
+ {layoutData.team.members.map((member, i) => ( + + + + + ))} +
+
+ ); +} + +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 ( +
+ {isTeamMember({ user, team }) && !isMainTeam ? ( + + ) : null} + {isTeamMember({ user, team }) ? ( + + + {t("team:actionButtons.leaveTeam")} + + + ) : null} + {isTeamManager({ user, team }) || isAdmin ? ( + } + testId="manage-roster-button" + > + {t("team:actionButtons.manageRoster")} + + ) : null} + {isTeamManager({ user, team }) || isAdmin ? ( + } + testId="edit-team-button" + > + {t("team:actionButtons.editTeam")} + + ) : null} +
+ ); +} + +function ChangeMainTeamButton() { + const { t } = useTranslation(["team"]); + const fetcher = useFetcher(); + + return ( + + } + testId="make-main-team-button" + > + {t("team:actionButtons.makeMainTeam")} + + + ); +} + +function ResultsBanner({ + results, +}: { + results: NonNullable; +}) { + return ( + +
View {results.count} results
+
    + {results.placements.map(({ placement, count }) => { + return ( +
  • + ×{count} +
  • + ); + })} +
+ + ); +} + +function MemberRow({ + member, + number, +}: { + member: TeamRepository.findByCustomUrl["members"][number]; + number: number; +}) { + const { t } = useTranslation(["team"]); + + return ( +
+ {member.role ? ( + + {t(`team:roles.${member.role}`)} + + ) : null} +
+ +
+ +
+ {member.username} + +
+ {member.weapons.map(({ weaponSplId, isFavorite }) => ( + + ))} +
+
+
+ ); +} + +function MobileMemberCard({ + member, +}: { + member: TeamRepository.findByCustomUrl["members"][number]; +}) { + const { t } = useTranslation(["team"]); + + return ( +
+
+ + +
{member.username}
+ + {member.weapons.length > 0 ? ( +
+ {member.weapons.map(({ weaponSplId, isFavorite }) => ( + + ))} +
+ ) : null} +
+ {member.role ? ( + + {t(`team:roles.${member.role}`)} + + ) : null} +
+ ); +} diff --git a/app/features/team/routes/t.$customUrl.results.tsx b/app/features/team/routes/t.$customUrl.results.tsx new file mode 100644 index 000000000..5c2f44ede --- /dev/null +++ b/app/features/team/routes/t.$customUrl.results.tsx @@ -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(); + + return ( +
+ + +
+ ); +} diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index b90c6b1e5..84b487571 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -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 | 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 (
+ = (args) => { if (!args.data) return []; @@ -84,26 +61,13 @@ export const handle: SendouRouteHandle = { }; export default function TeamPage() { - const { team } = useLoaderData(); - return ( -
+
- {/* */}
- - {/* {team.results ? : null} */} - {team.bio ?
{team.bio}
: null} -
- {team.members.map((member, i) => ( - - - - - ))} -
+
); } @@ -187,194 +151,3 @@ function BskyLink() { ); } - -function ActionButtons() { - const { t } = useTranslation(["team"]); - const user = useUser(); - const isAdmin = useHasRole("ADMIN"); - const { team } = useLoaderData(); - - if (!isTeamMember({ user, team }) && !isAdmin) { - return null; - } - - const isMainTeam = team.members.find( - (member) => user?.id === member.id && member.isMainTeam, - ); - - return ( -
- {isTeamMember({ user, team }) && !isMainTeam ? ( - - ) : null} - {isTeamMember({ user, team }) ? ( - - - {t("team:actionButtons.leaveTeam")} - - - ) : null} - {isTeamManager({ user, team }) || isAdmin ? ( - } - testId="manage-roster-button" - > - {t("team:actionButtons.manageRoster")} - - ) : null} - {isTeamManager({ user, team }) || isAdmin ? ( - } - testId="edit-team-button" - > - {t("team:actionButtons.editTeam")} - - ) : null} -
- ); -} - -function ChangeMainTeamButton() { - const { t } = useTranslation(["team"]); - const fetcher = useFetcher(); - - return ( - - } - testId="make-main-team-button" - > - {t("team:actionButtons.makeMainTeam")} - - - ); -} - -// function ResultsBanner({ results }: { results: TeamResultPeek }) { -// return ( -// -//
View {results.count} results
-//
    -// {results.placements.map(({ placement, count }) => { -// return ( -//
  • -// ×{count} -//
  • -// ); -// })} -//
-// -// ); -// } - -function MemberRow({ - member, - number, -}: { - member: TeamRepository.findByCustomUrl["members"][number]; - number: number; -}) { - const { t } = useTranslation(["team"]); - - return ( -
- {member.role ? ( - - {t(`team:roles.${member.role}`)} - - ) : null} -
- -
- -
- {member.username} - -
- {member.weapons.map(({ weaponSplId, isFavorite }) => ( - - ))} -
-
-
- ); -} - -function MobileMemberCard({ - member, -}: { - member: TeamRepository.findByCustomUrl["members"][number]; -}) { - const { t } = useTranslation(["team"]); - - return ( -
-
- - -
{member.username}
- - {member.weapons.length > 0 ? ( -
- {member.weapons.map(({ weaponSplId, isFavorite }) => ( - - ))} -
- ) : null} -
- {member.role ? ( - - {t(`team:roles.${member.role}`)} - - ) : null} -
- ); -} diff --git a/app/features/team/team-utils.test.ts b/app/features/team/team-utils.test.ts new file mode 100644 index 000000000..670104e48 --- /dev/null +++ b/app/features/team/team-utils.test.ts @@ -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([]); + }); +}); diff --git a/app/features/team/team-utils.ts b/app/features/team/team-utils.ts index de252e94c..3623a23c3 100644 --- a/app/features/team/team-utils.ts +++ b/app/features/team/team-utils.ts @@ -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( + result: { participants: Array; startTime: number }, + members: Array>, +) { + const currentMembers = members.filter((member) => !member.leftAt); + const pastMembers = members.filter((member) => member.leftAt); + + const subs = result.participants.reduce((acc: Array, 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; +} diff --git a/app/features/team/team.css b/app/features/team/team.css index c9e3eb4a7..c88ef22c6 100644 --- a/app/features/team/team.css +++ b/app/features/team/team.css @@ -174,6 +174,7 @@ justify-content: space-between; width: min(100%, 48rem); color: var(--text); + white-space: nowrap; } .team__results__placements { diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 4dd0a7734..e4f1aec99 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -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, diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 421da6373..b81ec76f2 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -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[]; playerResultDeltas: Omit[]; 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 | null; + /** Map of user id to set results */ + setResults: Map; } -type UserIdToTeamId = Record; - type TeamsArg = Array<{ id: number; members: Array<{ userId: number }>; }>; +type Rating = Pick; +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(); const userMatchesCount = new Map(); @@ -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()); + + 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(); + + 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(); + + 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); +} diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index e03292ffe..5ca6af6f3 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -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"]); + } + }); }); diff --git a/app/features/tournament-bracket/queries/addSummary.server.ts b/app/features/tournament-bracket/queries/addSummary.server.ts index 5bfa04306..5a4459ed3 100644 --- a/app/features/tournament-bracket/queries/addSummary.server.ts +++ b/app/features/tournament-bracket/queries/addSummary.server.ts @@ -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, }); } diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts index 50b61f992..92088af1b 100644 --- a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts @@ -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, diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index d4d69c18d..65597fbf6 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -432,8 +432,11 @@ const withMaxEventStartTime = (eb: ExpressionBuilder) => { .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`null`.as("tournamentId"), "CalendarEventResultTeam.placement", "CalendarEvent.participantCount", + sql`null`.as("setResults"), + sql`null`.as("logoUrl"), "CalendarEvent.name as eventName", "CalendarEventResultTeam.id as teamId", "CalendarEventResultTeam.name as teamName", + fn("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`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`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`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`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 }) => [ ...COMMON_USER_FIELDS, diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index ca2696938..62fc270b2 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -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({ {hasHighlightCheckboxes && } {t("results.placing")} - {t("results.team")} - {t("results.tournament")} {t("results.date")} - {t("results.mates")} + {t("results.tournament")} + {t("results.participation")} + {t("results.team")} @@ -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 ( {hasHighlightCheckboxes && ( @@ -77,72 +88,99 @@ export function UserResultsTable({ - - {result.tournamentId ? ( - - {result.teamName} - - ) : ( - result.teamName - )} - - - {result.eventId ? ( - - {result.eventName} - - ) : null} - {result.tournamentId ? ( - - {result.eventName} - - ) : null} - - + {databaseTimestampToDate(result.startTime).toLocaleDateString( i18n.language, { day: "numeric", - month: "long", + month: "short", year: "numeric", }, )} + +
+ {result.eventId ? ( + + {result.eventName} + + ) : null} + {result.tournamentId ? ( + <> + {logoUrl !== tournamentLogoUrl("default") ? ( + + ) : null} + + {result.eventName} + + + ) : null} +
+ -
    - {result.mates.map((player) => ( -
  • + + +
    + } + size="miniscule" + variant="minimal" + data-testid="mates-button" + /> + } + > +
      - {player.name ? ( - player.name - ) : ( - // as any but we know it's a user since it doesn't have name - ( +
    • - - {player.username} - - )} -
    • - ))} -
    + {player.name ? ( + player.name + ) : ( + // as any but we know it's a user since it doesn't have name + + + {player.username} + + )} +
  • + ))} +
+ + {result.tournamentId ? ( + + {result.teamName} + + ) : ( + result.teamName + )} + ); @@ -151,3 +189,32 @@ export function UserResultsTable({ ); } + +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 ( +
+
{playedPercentage}%
+
+ {setResults.map((result, i) => ( +
+ ))} +
+
+ ); +} diff --git a/app/features/user-page/loaders/u.$identifier.results.server.ts b/app/features/user-page/loaders/u.$identifier.results.server.ts index 9a98eed67..ae13d19ac 100644 --- a/app/features/user-page/loaders/u.$identifier.results.server.ts +++ b/app/features/user-page/loaders/u.$identifier.results.server.ts @@ -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; -// 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, }; }; diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts index ee5e54723..aa8f3aba7 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts @@ -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, diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index b78b8fcce..27da0e34b 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -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 (
@@ -49,12 +38,18 @@ export default function UserResultsPage() { ) : null}
- - {hasHighlightedResults ? ( + + {data.hasHighlightedResults ? ( setShowAll(!showAll)} + onPress={() => + setSearchParams((params) => { + params.set("all", showAll ? "false" : "true"); + + return params; + }) + } > {showAll ? t("results.button.showHighlights") diff --git a/app/features/user-page/routes/u.$identifier.seasons.tsx b/app/features/user-page/routes/u.$identifier.seasons.tsx index 084345297..4dd3f2e31 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.tsx +++ b/app/features/user-page/routes/u.$identifier.seasons.tsx @@ -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 (
- `?info=${tab}&page=${data.matches.currentPage}&season=${data.season}`; + `?info=${tab}&page=${data.results.currentPage}&season=${data.season}`; return (
@@ -157,7 +167,10 @@ export default function UserSeasonsPage() { ) : null}
- + {data.canceled ? ( + + ) : null} +
); } @@ -650,12 +663,44 @@ function WeaponCircle({ ); } -function Matches({ +/** Dialog for staff view all season's canceled matches per user */ +function CanceledMatchesDialog({ + canceledMatches, +}: { + canceledMatches: NonNullable; +}) { + return ( + + Canceled Matches ({canceledMatches.length}) + + } + heading="Season's canceled matches for this user" + > +
+ {canceledMatches.map((match) => ( +
+ #{match.id} +
+ {databaseTimestampToDate(match.createdAt).toLocaleString()} +
+
+ ))} +
+
+ ); +} + +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({
- {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 ( - +
{isMounted - ? databaseTimestampToDate(match.createdAt).toLocaleString( + ? databaseTimestampToDate(result.createdAt).toLocaleString( "en", { weekday: "long", @@ -704,17 +749,21 @@ function Matches({ ) : "t"}
- + {result.type === "GROUP_MATCH" ? ( + + ) : ( + + )}
); })}
- {matches.pages > 1 ? ( + {results.pages > 1 ? ( 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({ , , ] @@ -773,13 +809,13 @@ function Match({ , , ]; @@ -789,8 +825,7 @@ function Match({ {rows} @@ -805,10 +840,49 @@ function Match({ {Math.abs(roundToNDecimalPlaces(match.spDiff))}SP
) : null} - {!match.isLocked ? ( +
+ ); +} + +function TournamentResult({ result }: { result: SeasonTournamentResult }) { + const logoUrl = result.logoUrl + ? userSubmittedImage(result.logoUrl) + : HACKY_resolvePicture({ name: result.tournamentName }); + + return ( +
+ +
+ + {result.tournamentName} +
+
    + {result.setResults.filter(Boolean).map((result, i) => ( +
  • + {result} +
  • + ))} +
+ + {result.spDiff ? (
- - {t("user:seasons.matchBeingProcessed")} + {result.spDiff > 0 ? ( + + ) : ( + + )} + {Math.abs(roundToNDecimalPlaces(result.spDiff))}SP
) : null}
@@ -821,7 +895,7 @@ function MatchMembersRow({ reserveWeaponSpace, }: { score: React.ReactNode; - members: UserSeasonsPageLoaderData["matches"]["value"][0]["groupAlphaMembers"]; + members: SeasonGroupMatch["groupAlphaMembers"]; reserveWeaponSpace: boolean; }) { return ( diff --git a/app/features/user-page/routes/u.$identifier.tsx b/app/features/user-page/routes/u.$identifier.tsx index a92a2854e..a36535f87 100644 --- a/app/features/user-page/routes/u.$identifier.tsx +++ b/app/features/user-page/routes/u.$identifier.tsx @@ -75,44 +75,63 @@ export default function UserPageLayout() { return (
- + {t("common:header.profile")} - + {t("user:seasons")} - {isOwnPage && ( - + {isOwnPage ? ( + {t("common:actions.edit")} - )} - {allResultsCount > 0 && ( - + ) : null} + {allResultsCount > 0 ? ( + {t("common:results")} ({allResultsCount}) - )} - {(data.user.buildsCount > 0 || isOwnPage) && ( + ) : null} + {data.user.buildsCount > 0 || isOwnPage ? ( {t("common:pages.builds")} ({data.user.buildsCount}) - )} - {(data.user.vodsCount > 0 || isOwnPage) && ( - + ) : null} + {data.user.vodsCount > 0 || isOwnPage ? ( + {t("common:pages.vods")} ({data.user.vodsCount}) - )} - {(data.user.artCount > 0 || isOwnPage) && ( - + ) : null} + {data.user.artCount > 0 || isOwnPage ? ( + {t("common:pages.art")} ({data.user.artCount}) - )} - {isStaff && ( - Admin - )} + ) : null} + {isStaff ? ( + + Admin + + ) : null}
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 31f0a5245..308a8665e 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -158,3 +158,7 @@ export const adminTabActionSchema = z.union([ addModNoteSchema, deleteModNoteSchema, ]); + +export const userResultsPageSearchParamsSchema = z.object({ + all: z.stringbool(), +}); diff --git a/app/routes.ts b/app/routes.ts index 35ef4602c..92ab3916c 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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", [ diff --git a/app/styles/u.css b/app/styles/u.css index 345c26353..45a0c86d5 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -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); diff --git a/app/utils/arrays.test.ts b/app/utils/arrays.test.ts index e5d6062be..4d2789525 100644 --- a/app/utils/arrays.test.ts +++ b/app/utils/arrays.test.ts @@ -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"); + }); +}); diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts index d287ce0cd..246f0e081 100644 --- a/app/utils/arrays.ts +++ b/app/utils/arrays.ts @@ -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(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(arr1: T[], arr2: T[]): T[] { return result; } + +export function mostPopularArrayElement(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; +} diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 61eee41ac..53dc96261 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index a7f05ec36..be67ad926 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -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", ); diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 5e0383149..af54731dd 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -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 ({ diff --git a/locales/da/common.json b/locales/da/common.json index 46bf16843..0310bcbda 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Lav bane-liste", "maps.halfSz": "50% DD", diff --git a/locales/da/user.json b/locales/da/user.json index b55092bd6..8d86a6dee 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 97110ff4c..bec07d624 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Arenen-Liste erstellen", "maps.halfSz": "50% Herrschaft", diff --git a/locales/de/user.json b/locales/de/user.json index 948231869..4cbb680f1 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -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": "", diff --git a/locales/en/common.json b/locales/en/common.json index 398a74d61..b79872f20 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/en/user.json b/locales/en/user.json index dc9ccc7b1..27becbb07 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 078e81482..eb1e9b26e 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Crear lista de mapas", "maps.halfSz": "50% Pintazonas", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 81ef8d89b..5c5fe3e80 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -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": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 3390cef34..5c9b8f6b2 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Crear lista de escenarios", "maps.halfSz": "50% Pintazonas", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index db63733ff..b202b445b 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -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", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index e3a1501c4..e2b39ea73 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index e8138f1b5..8720da5b9 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -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": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 9196d229e..060d9d29f 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 43e2d0aa8..222873072 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 2552b86a7..c696fef60 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "יצירת רשימת מפות", "maps.halfSz": "50% SZ", diff --git a/locales/he/user.json b/locales/he/user.json index 744c46e39..79d925434 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -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": "", diff --git a/locales/it/common.json b/locales/it/common.json index 63d67d8dc..e0d26fc61 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Crea lista scenari", "maps.halfSz": "50% ZS", diff --git a/locales/it/user.json b/locales/it/user.json index 0910aa351..22d6a927a 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index f65bf80d6..b73f83c49 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "ステージ一覧を作る", "maps.halfSz": "ガチエリア (2ヶ所)", diff --git a/locales/ja/user.json b/locales/ja/user.json index 70719032f..d0619caf4 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -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": "デフォルトに戻す", diff --git a/locales/ko/common.json b/locales/ko/common.json index 3e931d958..7048ba872 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "맵 목록 생성", "maps.halfSz": "에어리어 50%", diff --git a/locales/ko/user.json b/locales/ko/user.json index eb8f39375..916205a28 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -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": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 031630a0c..9ef3d1f7c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Maak levellijst", "maps.halfSz": "50% SZ", diff --git a/locales/nl/user.json b/locales/nl/user.json index b5b963b52..8c12791d8 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -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": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index e6aa1f252..2ddac2801 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Stwórz liste map", "maps.halfSz": "50% SZ", diff --git a/locales/pl/user.json b/locales/pl/user.json index ef7d2d52e..fa51619f3 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -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": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 5dde1e314..a6058bf61 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "Criar lista de mapas", "maps.halfSz": "50% Zones", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index bad496819..f33c9f8d1 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -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": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index b56c3e0a1..a85768334 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -122,6 +122,7 @@ "actions.accept": "Подтвердить", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "Нет результатов", "maps.createMapList": "Создать список карт", "maps.halfSz": "50% Зон", diff --git a/locales/ru/user.json b/locales/ru/user.json index 718679746..6102f737f 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -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": "По умолчанию", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4996a122d..16b21efe3 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -122,6 +122,7 @@ "actions.accept": "", "actions.next": "", "actions.previous": "", + "actions.back": "", "noResults": "", "maps.createMapList": "创建地图列表", "maps.halfSz": "50%为真格区域", diff --git a/locales/zh/user.json b/locales/zh/user.json index db384ed5c..caa60ef53 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -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": "回到默认值", diff --git a/migrations/093-ranked-tournament-results.js b/migrations/093-ranked-tournament-results.js new file mode 100644 index 000000000..0f4d543ae --- /dev/null +++ b/migrations/093-ranked-tournament-results.js @@ -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(); + })(); +} diff --git a/scripts/calc-seeding-skills.ts b/scripts/calc-seeding-skills.ts index c4ce2ddd8..1f607883b 100644 --- a/scripts/calc-seeding-skills.ts +++ b/scripts/calc-seeding-skills.ts @@ -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, }); diff --git a/scripts/calc-tournament-summary-result-arrays.ts b/scripts/calc-tournament-summary-result-arrays.ts new file mode 100644 index 000000000..767a1f2b1 --- /dev/null +++ b/scripts/calc-tournament-summary-result-arrays.ts @@ -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 + > = []; + + 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`instr(setResults, 'W') = 0`) + .where(sql`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); +});