Tournament cards show participants count

Closes #3316
This commit is contained in:
Kalle
2026-08-09 17:35:24 +03:00
parent c2ca58a3bc
commit 2f9983a049
8 changed files with 140 additions and 49 deletions

View File

@@ -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<DB, "CalendarEvent">) =>
const withTeamsCount = (
eb: ExpressionBuilder<DB, "CalendarEventDate" | "Tournament">,
) =>
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<number>().as("teamsCount")]);
tournamentCheckedInTeams(eb).select(({ fn }) => [
fn.countAll<number>().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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 ? (
<div className={styles.vodIndicator}>📺 VODs</div>
) : null}
{tournament.modes ? <ModesPill modes={tournament.modes} /> : null}
{modes ? <ModesPill modes={modes} /> : null}
<div
className={clsx(styles.pillsContainer, {
[styles.lonely]: !tournament.modes && isHostedOnSendouInk,
[styles.lonely]: !modes && isHostedOnSendouInk,
})}
>
{tournament.isRanked ? (
@@ -155,9 +160,11 @@ export function TournamentCard({
/>
) : null}
{isHostedOnSendouInk ? (
<div className={styles.teamCount}>
<Users /> {tournament.teamsCount}
</div>
<ParticipantsPill
teamsCount={tournament.teamsCount}
membersCount={tournament.membersCount}
minMembersPerTeam={tournament.minMembersPerTeam}
/>
) : null}
</div>
</div>
@@ -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 (
<div className={styles.participantsPill}>
<Users />
<span>
{isSolo ? teamsCount : membersCount}
{isSolo ? null : (
<span className={styles.participantsTeamsCount}>/{teamsCount}</span>
)}
</span>
</div>
);
}
function ModesPill({ modes }: { modes: NonNullable<CalendarEvent["modes"]> }) {
const size = 16;
@@ -334,3 +365,11 @@ function PrizesPill({
</SendouPopover>
);
}
/** Modes pill is only interesting when the tournament deviates from the standard ranked modes */
function isDefaultModes(modes: NonNullable<CalendarEvent["modes"]>) {
return (
modes.length === rankedModesShort.length &&
rankedModesShort.every((mode) => modes.includes(mode))
);
}

View File

@@ -17,6 +17,8 @@ function makeEvent(
tags: [],
modes: ["SZ"],
teamsCount: 2,
membersCount: 8,
minMembersPerTeam: 4,
organization: null,
authorId: 1,
type: "calendar",

View File

@@ -304,6 +304,7 @@ function mapTournamentFromDB(
name: tournament.name,
startsAt: tournament.startsAt,
teamsCount: tournament.teamsCount,
membersCount: tournament.membersCount,
logoUrl: tournament.logoUrl,
organization: tournament.organization
? {

View File

@@ -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<number>("TournamentTeam.id").distinct().as("teamsCount"),
fn.count<number>("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();
}

View File

@@ -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<DB, "CalendarEventDate" | "Tournament">,
) {
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<DB, "CalendarEventDate" | "Tournament">,
) {
return eb
.case()
.when("Tournament.isFinalized", "=", 1)
.then(
eb
.selectFrom("TournamentResult")
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
.select(({ fn }) => [
fn.count<number>("TournamentResult.userId").distinct().as("count"),
]),
)
.else(
tournamentCheckedInTeams(eb)
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select(({ fn }) => [
fn
.count<number>("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`