mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 14:46:10 -05:00
Show titles in event's series on the exported run image
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
19
app/features/tournament-organization/core/Series.ts
Normal file
19
app/features/tournament-organization/core/Series.ts
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
5
changelog/2026-08-29-run-image-series-titles.md
Normal file
5
changelog/2026-08-29-run-image-series-titles.md
Normal 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
|
||||
Reference in New Issue
Block a user