Show titles in event's series on the exported run image
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-08-29 21:09:23 +03:00
parent 57037d7d10
commit 3c386529b5
7 changed files with 409 additions and 9 deletions

View File

@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
import { seedOrgEventWithParticipants } from "./test-utils";
@@ -211,3 +216,201 @@ describe("countActiveParticipants", () => {
expect(await countForOrg(org.id)).toBe(0);
});
});
describe("findAllSeriesWinsByUserId", () => {
const FIRST_EVENT_STARTED_AT = 1_700_000_000;
const DAY_IN_SECONDS = 60 * 60 * 24;
const winnerId = () => users.id(1);
const loserId = () => users.id(2);
beforeEach(async () => {
await users.create(2);
});
const seedPlayedEvent = async ({
organizationId,
name,
startTime,
winnerUserId = winnerId(),
}: {
organizationId: number;
name: string;
startTime: number;
winnerUserId?: number;
}) => {
const loserUserId = winnerUserId === winnerId() ? loserId() : winnerId();
const { id } = await TournamentFactory.createPlayed(
{
authorId: winnerUserId,
organizationId,
name,
startTimes: [startTime],
minMembersPerTeam: 1,
},
{ teamRosters: [[winnerUserId], [loserUserId]], playedOut: "all" },
);
return id;
};
const winsInSeries = ({
organizationId,
excludeTournamentId = 0,
}: {
organizationId: number;
excludeTournamentId?: number;
}) =>
TournamentOrganizationRepository.findAllSeriesWinsByUserId({
organizationId,
substringMatches: ["Low Ink"],
userId: winnerId(),
excludeTournamentId,
});
test("returns the events of the series won by the user, oldest first", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
await seedPlayedEvent({
organizationId: org.id,
name: "Low Ink February",
startTime: FIRST_EVENT_STARTED_AT + DAY_IN_SECONDS,
});
await seedPlayedEvent({
organizationId: org.id,
name: "Low Ink January",
startTime: FIRST_EVENT_STARTED_AT,
});
const wins = await winsInSeries({ organizationId: org.id });
expect(wins.map((win) => win.name)).toEqual([
"Low Ink January",
"Low Ink February",
]);
expect(wins[0].startTime).toEqual(
databaseTimestampToDate(FIRST_EVENT_STARTED_AT),
);
});
const seedReportedEvent = async ({
organizationId,
name,
startTime,
winnerUserId = winnerId(),
}: {
organizationId: number;
name: string;
startTime: number;
winnerUserId?: number;
}) => {
const event = await CalendarEventFactory.create({
authorId: winnerUserId,
organizationId,
name,
startTimes: [startTime],
});
await CalendarEventResultFactory.create({
eventId: event.id,
participantCount: 2,
results: [
{
teamName: "Winners",
placement: 1,
players: [{ userId: winnerUserId, name: null }],
},
],
});
};
test("includes events whose results were reported by hand", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
await seedReportedEvent({
organizationId: org.id,
name: "Low Ink January",
startTime: FIRST_EVENT_STARTED_AT,
});
await seedReportedEvent({
organizationId: org.id,
name: "Low Ink February",
startTime: FIRST_EVENT_STARTED_AT + DAY_IN_SECONDS,
winnerUserId: loserId(),
});
const wins = await winsInSeries({ organizationId: org.id });
expect(wins.map((win) => win.name)).toEqual(["Low Ink January"]);
});
test("excludes events of the organization outside the series", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
await seedPlayedEvent({
organizationId: org.id,
name: "Paddling Pool",
startTime: FIRST_EVENT_STARTED_AT,
});
expect(await winsInSeries({ organizationId: org.id })).toHaveLength(0);
});
test("excludes events of another organization", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
const otherOrg = await TournamentOrganizationFactory.create({
ownerId: users.id(2),
});
await seedPlayedEvent({
organizationId: otherOrg.id,
name: "Low Ink January",
startTime: FIRST_EVENT_STARTED_AT,
});
expect(await winsInSeries({ organizationId: org.id })).toHaveLength(0);
});
test("excludes events the user did not win", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
await seedPlayedEvent({
organizationId: org.id,
name: "Low Ink January",
startTime: FIRST_EVENT_STARTED_AT,
winnerUserId: loserId(),
});
expect(await winsInSeries({ organizationId: org.id })).toHaveLength(0);
});
test("excludes the tournament the wins are looked up for", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
const tournamentId = await seedPlayedEvent({
organizationId: org.id,
name: "Low Ink January",
startTime: FIRST_EVENT_STARTED_AT,
});
expect(
await winsInSeries({
organizationId: org.id,
excludeTournamentId: tournamentId,
}),
).toHaveLength(0);
});
});

View File

@@ -1,7 +1,8 @@
import { isFuture } from "date-fns";
import { sql } from "kysely";
import { type ExpressionBuilder, sql } from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import type { DB, Tables, TablesInsertable } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import {
TIER_HISTORY_LENGTH,
@@ -460,6 +461,107 @@ export async function findAllEventsBySeries({
return events.map(mapEvent);
}
export function findAllSeriesByOrganizationId(organizationId: number) {
return db
.selectFrom("TournamentOrganizationSeries")
.select([
"TournamentOrganizationSeries.id",
"TournamentOrganizationSeries.name",
"TournamentOrganizationSeries.substringMatches",
])
.where("TournamentOrganizationSeries.organizationId", "=", organizationId)
.execute();
}
/**
* Events of the series that the user won, oldest first. Both tournaments hosted on the site
* and events whose results were reported by hand count. Only finalized tournaments have
* results, so an event that is still ongoing is never included.
*/
export async function findAllSeriesWinsByUserId({
organizationId,
substringMatches,
userId,
excludeTournamentId,
}: {
organizationId: number;
substringMatches: string[];
userId: number;
excludeTournamentId: number;
}) {
const isEventOfTheSeries = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
eb.and([
eb("CalendarEvent.organizationId", "=", organizationId),
eb("CalendarEvent.hidden", "=", 0),
eb.or(
substringMatches.map((match) =>
eb("CalendarEvent.name", "like", `%${match}%`),
),
),
]);
const [tournamentWins, reportedWins] = await Promise.all([
db
.selectFrom("TournamentResult")
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentResult.tournamentId",
)
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select(({ fn }) => [
"CalendarEvent.name",
fn.min("CalendarEventDate.startsAt").as("startsAt"),
])
.where("TournamentResult.userId", "=", userId)
.where("TournamentResult.placement", "=", 1)
.where("TournamentResult.tournamentId", "!=", excludeTournamentId)
.where(isEventOfTheSeries)
.groupBy("CalendarEvent.id")
.execute(),
db
.selectFrom("CalendarEventResultPlayer")
.innerJoin(
"CalendarEventResultTeam",
"CalendarEventResultTeam.id",
"CalendarEventResultPlayer.teamId",
)
.innerJoin(
"CalendarEvent",
"CalendarEvent.id",
"CalendarEventResultTeam.eventId",
)
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select(({ fn }) => [
"CalendarEvent.name",
fn.min("CalendarEventDate.startsAt").as("startsAt"),
])
.where("CalendarEventResultPlayer.userId", "=", userId)
.where("CalendarEventResultTeam.placement", "=", 1)
// a tournament of the site reports its own results, counted above
.where("CalendarEvent.tournamentId", "is", null)
.where(isEventOfTheSeries)
.groupBy("CalendarEvent.id")
.execute(),
]);
return R.sortBy(
[...tournamentWins, ...reportedWins].map((win) => ({
name: win.name,
startTime: databaseTimestampToDate(win.startsAt),
})),
(win) => win.startTime.getTime(),
);
}
/**
* Counts the distinct players who participated in at least one match of a
* tournament hosted by the organization, whose event started within the

View File

@@ -0,0 +1,19 @@
/**
* Finds the series that the event belongs to, matched by any of the series'
* substrings appearing in the event's name.
*/
export function findByEventName<T extends { substringMatches: string[] }>({
series,
eventName,
}: {
series: T[];
eventName: string;
}) {
const eventNameLower = eventName.toLowerCase();
return series.find((oneSeries) =>
oneSeries.substringMatches.some((substringMatch) =>
eventNameLower.includes(substringMatch.toLowerCase()),
),
);
}

View File

@@ -1,5 +1,6 @@
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import * as Series from "./Series";
interface SeriesMatch {
substringMatches: string[];
@@ -36,10 +37,10 @@ export function getTentativeTier(
const seriesList = cache.get(orgId);
if (!seriesList) return null;
const nameLower = tournamentName.toLowerCase();
const match = seriesList.find((s) =>
s.substringMatches.some((m) => nameLower.includes(m.toLowerCase())),
);
const match = Series.findByEventName({
series: seriesList,
eventName: tournamentName,
});
return match?.tentativeTier ?? null;
}

View File

@@ -6,6 +6,8 @@ import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeap
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import * as Series from "~/features/tournament-organization/core/Series";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { forbidden, parseParams } from "~/utils/remix.server";
@@ -97,9 +99,46 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return {
ownComp: fullCompOnly(RunComps.buildComp(ownObservations)),
opponentComps,
previousSeriesWins: await previousSeriesWins({
organizationId: tournament.ctx.organization?.id,
tournamentName: tournament.ctx.name,
tournamentId,
userId: user.id,
}),
};
function fullCompOnly(comp: MainWeaponId[]) {
return comp.length >= minMembersPerTeam ? comp : [];
}
};
/** Wins of the user in the series this tournament belongs to, `null` if it belongs to none. */
async function previousSeriesWins({
organizationId,
tournamentName,
tournamentId,
userId,
}: {
organizationId?: number;
tournamentName: string;
tournamentId: number;
userId: number;
}) {
if (!organizationId) return null;
const series = Series.findByEventName({
series:
await TournamentOrganizationRepository.findAllSeriesByOrganizationId(
organizationId,
),
eventName: tournamentName,
});
if (!series) return null;
return TournamentOrganizationRepository.findAllSeriesWinsByUserId({
organizationId,
substringMatches: series.substringMatches,
userId,
excludeTournamentId: tournamentId,
});
}

View File

@@ -10,7 +10,10 @@ import { Placement } from "~/components/Placement";
import { UserLink } from "~/components/UserLink";
import { useUser } from "~/features/auth/core/user";
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
import { TournamentRunGraphic } from "~/features/img-export/components/TournamentRunGraphic";
import {
TournamentRunGraphic,
type TournamentRunGraphicSeriesWin,
} from "~/features/img-export/components/TournamentRunGraphic";
import { useTournament } from "~/features/tournament/tournament-context";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
@@ -192,6 +195,15 @@ function RunImageExport() {
]),
);
const seriesWins = seriesWinsForGraphic({
placement: data.placement,
previousWins: fetcher.data?.previousSeriesWins,
currentWin: {
name: tournament.ctx.name,
startTime: tournament.ctx.startsAt,
},
});
const activePlayers = data.activePlayers ?? [];
const ownPlayers =
activePlayers.length > 0
@@ -251,7 +263,6 @@ function RunImageExport() {
filename={`tournament-${tournament.ctx.id}-run`}
>
{fetcher.data ? (
// xxx: pass seriesWins so 1st place finishes show the series titles row
<TournamentRunGraphic
tournamentId={tournament.ctx.id}
tournamentTeamId={data.tournamentTeamId}
@@ -272,6 +283,7 @@ function RunImageExport() {
matches={matches}
teamsCount={tournament.ctx.teams.length}
playersCount={data.participatedUsersCount}
seriesWins={seriesWins}
/>
) : null}
</ImageExportDialog>
@@ -398,3 +410,22 @@ function SetInfo({
</div>
);
}
/** The current win is part of the count, but only shown when the team has no earlier title. */
function seriesWinsForGraphic({
placement,
previousWins,
currentWin,
}: {
placement?: number;
previousWins?: TournamentRunGraphicSeriesWin[] | null;
currentWin: TournamentRunGraphicSeriesWin;
}) {
if (placement !== 1 || !previousWins) return;
return {
totalCount: previousWins.length + 1,
first: previousWins[0] ?? currentWin,
latest: previousWins.length > 1 ? previousWins.at(-1) : undefined,
};
}

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: feature
---
Winning a tournament shows your titles in that event's series on the exported run image