diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 0a91d3e87..7c53d31bd 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -25,7 +25,9 @@ import { concatUserSubmittedImagePrefix, jsonArrayFrom, jsonObjectFrom, + tournamentCheckedInTeams, tournamentLogoWithDefault, + tournamentMembersCount, } from "~/utils/kysely.server"; import { calendarEventPage, tournamentPage } from "~/utils/urls"; import { @@ -141,26 +143,9 @@ const withOrganization = (eb: ExpressionBuilder) => const withTeamsCount = ( eb: ExpressionBuilder, ) => - eb - .selectFrom("TournamentTeam") - .leftJoin("TournamentTeamCheckIn", (join) => - join - .on("TournamentTeamCheckIn.bracketIdx", "is", null) - .onRef( - "TournamentTeamCheckIn.tournamentTeamId", - "=", - "TournamentTeam.id", - ), - ) - .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") - .where("TournamentTeam.isPlaceholder", "=", 0) - .where((eb) => - eb.or([ - eb("TournamentTeamCheckIn.checkedInAt", "is not", null), - eb("CalendarEventDate.startsAt", ">", databaseTimestampNow()), - ]), - ) - .select(({ fn }) => [fn.countAll().as("teamsCount")]); + tournamentCheckedInTeams(eb).select(({ fn }) => [ + fn.countAll().as("count"), + ]); function findAllBetweenTwoTimestampsQuery({ startTime, @@ -191,6 +176,7 @@ function findAllBetweenTwoTimestampsQuery({ ), withOrganization(eb).as("organization"), withTeamsCount(eb).as("teamsCount"), + tournamentMembersCount(eb).as("membersCount"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonArrayFrom( eb @@ -224,7 +210,7 @@ function findAllBetweenTwoTimestampsQuery({ dateToDatabaseTimestamp(startTime), ) .where("CalendarEventDate.startsAt", "<=", dateToDatabaseTimestamp(endTime)) - .$narrowType<{ teamsCount: NotNull }>() + .$narrowType<{ teamsCount: NotNull; membersCount: NotNull }>() .execute(); } @@ -260,6 +246,8 @@ function findAllBetweenTwoTimestampsMapped( authorId: row.authorId, tags: tags.filter((tag) => !EXCLUDED_TAGS.includes(tag)), teamsCount: row.teamsCount, + membersCount: row.membersCount, + minMembersPerTeam: row.tournamentSettings?.minMembersPerTeam ?? 4, normalizedTeamCount: normalizedTeamCount({ teamsCount: row.teamsCount, minMembersPerTeam: row.tournamentSettings?.minMembersPerTeam ?? 4, diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index 152e7b7d4..6fb7f683b 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -11,6 +11,9 @@ interface CommonEvent { id: number; name: string; teamsCount: number; + /** How many players in total are rostered in the counted teams */ + membersCount: number; + minMembersPerTeam: number; logoUrl: string | null; url: string; /** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */ @@ -51,7 +54,6 @@ export interface ShowcaseCalendarEvent extends CommonEvent { /** Tournament is hidden from the public (test tournament) */ hidden: boolean; isFinalized: boolean; - minMembersPerTeam: number; firstPlacers: Array<{ teamName: string; logoUrl: string | null; diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index 9a5f43a3c..da83b8317 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -81,7 +81,7 @@ max-width: 165px; } -.teamCount { +.participantsPill { font-size: var(--font-2xs); font-weight: var(--weight-bold); background-color: var(--color-bg-higher); @@ -92,6 +92,7 @@ display: flex; align-items: center; gap: var(--s-1); + white-space: nowrap; & svg { width: var(--tournament-card-icon-size); @@ -99,6 +100,11 @@ } } +.participantsTeamsCount { + color: var(--color-text-high); + font-weight: var(--weight-semi); +} + .modesPillContainer { padding-inline-start: 12px; } diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx index c602d8018..ad2d219a0 100644 --- a/app/features/calendar/components/TournamentCard.tsx +++ b/app/features/calendar/components/TournamentCard.tsx @@ -13,6 +13,7 @@ import { Trophy } from "~/features/trophies/components/Trophy"; import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow"; import { useHydrated } from "~/hooks/useHydrated"; import { useSpoilerFree } from "~/hooks/useSpoilerFree"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { databaseTimestampToDate } from "~/utils/dates"; import { navIconUrl } from "~/utils/urls"; import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types"; @@ -35,6 +36,10 @@ export function TournamentCard({ const isShowcase = tournament.type === "showcase"; const isCalendar = tournament.type === "calendar"; const isHostedOnSendouInk = typeof tournament.isRanked === "boolean"; + const modes = + tournament.modes && !isDefaultModes(tournament.modes) + ? tournament.modes + : null; const startDate = isShowcase ? databaseTimestampToDate(tournament.startsAt) @@ -135,10 +140,10 @@ export function TournamentCard({ {isShowcase && "hasVods" in tournament && tournament.hasVods ? (
📺 VODs
) : null} - {tournament.modes ? : null} + {modes ? : null}
{tournament.isRanked ? ( @@ -155,9 +160,11 @@ export function TournamentCard({ /> ) : null} {isHostedOnSendouInk ? ( -
- {tournament.teamsCount} -
+ ) : null}
@@ -284,6 +291,30 @@ function SpoilerRevealPill({ onReveal }: { onReveal: () => void }) { ); } +function ParticipantsPill({ + teamsCount, + membersCount, + minMembersPerTeam, +}: { + teamsCount: number; + membersCount: number; + minMembersPerTeam: number; +}) { + const isSolo = minMembersPerTeam === 1; + + return ( +
+ + + {isSolo ? teamsCount : membersCount} + {isSolo ? null : ( + /{teamsCount} + )} + +
+ ); +} + function ModesPill({ modes }: { modes: NonNullable }) { const size = 16; @@ -334,3 +365,11 @@ function PrizesPill({ ); } + +/** Modes pill is only interesting when the tournament deviates from the standard ranked modes */ +function isDefaultModes(modes: NonNullable) { + return ( + modes.length === rankedModesShort.length && + rankedModesShort.every((mode) => modes.includes(mode)) + ); +} diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts index 12dd4d207..0a9d14080 100644 --- a/app/features/calendar/core/CalendarEvent.test.ts +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -17,6 +17,8 @@ function makeEvent( tags: [], modes: ["SZ"], teamsCount: 2, + membersCount: 8, + minMembersPerTeam: 4, organization: null, authorId: 1, type: "calendar", diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index fa847a68e..4d73c7684 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -304,6 +304,7 @@ function mapTournamentFromDB( name: tournament.name, startsAt: tournament.startsAt, teamsCount: tournament.teamsCount, + membersCount: tournament.membersCount, logoUrl: tournament.logoUrl, organization: tournament.organization ? { diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index ce2010178..390f8a067 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -26,7 +26,9 @@ import { concatUserSubmittedImagePrefix, jsonArrayFrom, jsonObjectFrom, + tournamentCheckedInTeams, tournamentLogoWithDefault, + tournamentMembersCount, tournamentUsername, } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; @@ -798,29 +800,12 @@ export function findAllForShowcase() { "CalendarEvent.organizationId", "CalendarEventDate.startsAt", "CalendarEvent.hidden", - eb - .selectFrom("TournamentTeam") - .leftJoin("TournamentTeamCheckIn", (join) => - join - .on("TournamentTeamCheckIn.bracketIdx", "is", null) - .onRef( - "TournamentTeamCheckIn.tournamentTeamId", - "=", - "TournamentTeam.id", - ), - ) - .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") - .where("TournamentTeam.isPlaceholder", "=", 0) - .where((eb) => - eb.or([ - eb("TournamentTeamCheckIn.checkedInAt", "is not", null), - eb("CalendarEventDate.startsAt", ">", databaseTimestampNow()), - ]), - ) + tournamentCheckedInTeams(eb) .select(({ fn }) => [ - fn.count("TournamentTeam.id").distinct().as("teamsCount"), + fn.count("TournamentTeam.id").distinct().as("count"), ]) .as("teamsCount"), + tournamentMembersCount(eb).as("membersCount"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonObjectFrom( eb @@ -888,7 +873,7 @@ export function findAllForShowcase() { ]) .where("CalendarEventDate.startsAt", ">", databaseTimestampWeekAgo()) .orderBy("CalendarEventDate.startsAt", "asc") - .$narrowType<{ teamsCount: NotNull }>() + .$narrowType<{ teamsCount: NotNull; membersCount: NotNull }>() .execute(); } diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index f7129e307..33b24db61 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -19,6 +19,7 @@ import { } from "~/db/json-selections"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; +import { databaseTimestampNow } from "./dates"; import { IS_E2E_TEST_RUN } from "./e2e"; import { safeNumberParse } from "./number"; @@ -289,6 +290,73 @@ export function tournamentTeamCount( .where("TournamentTeam.isPlaceholder", "=", 0); } +/** + * Subquery resolving to a tournament's non-placeholder teams that are either checked in to the + * tournament itself (not to a specific bracket) or belong to a tournament that has not started yet. + * Correlates on `"Tournament"."id"` and `"CalendarEventDate"."startsAt"`. Has no select of its own, + * so extend it with the aggregate the caller needs, e.g. `.select(({ fn }) => fn.countAll().as("count"))`. + */ +export function tournamentCheckedInTeams( + eb: ExpressionBuilder, +) { + return eb + .selectFrom("TournamentTeam") + .leftJoin("TournamentTeamCheckIn", (join) => + join + .on("TournamentTeamCheckIn.bracketIdx", "is", null) + .onRef( + "TournamentTeamCheckIn.tournamentTeamId", + "=", + "TournamentTeam.id", + ), + ) + .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") + .where("TournamentTeam.isPlaceholder", "=", 0) + .where((eb2) => + eb2.or([ + eb2("TournamentTeamCheckIn.checkedInAt", "is not", null), + eb2("CalendarEventDate.startsAt", ">", databaseTimestampNow()), + ]), + ); +} + +/** + * Expression resolving to a tournament's participant count: rostered players of the teams from + * {@link tournamentCheckedInTeams} while the tournament is still to come, players who actually got + * a result once it has been finalized. Correlates on `"Tournament"."id"`. Alias it + * `.as("membersCount")` when selecting it directly. + */ +export function tournamentMembersCount( + eb: ExpressionBuilder, +) { + return eb + .case() + .when("Tournament.isFinalized", "=", 1) + .then( + eb + .selectFrom("TournamentResult") + .whereRef("TournamentResult.tournamentId", "=", "Tournament.id") + .select(({ fn }) => [ + fn.count("TournamentResult.userId").distinct().as("count"), + ]), + ) + .else( + tournamentCheckedInTeams(eb) + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .select(({ fn }) => [ + fn + .count("TournamentTeamMember.userId") + .distinct() + .as("count"), + ]), + ) + .end(); +} + /** * Grouped subquery picking each user's (`by: "userId"`) or team's (`by: "identifier"`) latest * Skill row of a season: `latestId` plus that row's `ordinal`, `matchesCount` and the `by`