From 3c386529b56b511d20e7adf522e1cb6a44fb1101 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:09:23 +0300 Subject: [PATCH] Show titles in event's series on the exported run image --- ...amentOrganizationRepository.server.test.ts | 205 +++++++++++++++++- ...TournamentOrganizationRepository.server.ts | 106 ++++++++- .../tournament-organization/core/Series.ts | 19 ++ .../core/tentativeTiers.server.ts | 9 +- .../loaders/to.$id.teams.$tid.comps.server.ts | 39 ++++ .../tournament/routes/to.$id.teams.$tid.tsx | 35 ++- .../2026-08-29-run-image-series-titles.md | 5 + 7 files changed, 409 insertions(+), 9 deletions(-) create mode 100644 app/features/tournament-organization/core/Series.ts create mode 100644 changelog/2026-08-29-run-image-series-titles.md diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index 8f20ece18..90997608a 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -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); + }); +}); diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index a4864a8ed..2a67ae5b6 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -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) => + 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 diff --git a/app/features/tournament-organization/core/Series.ts b/app/features/tournament-organization/core/Series.ts new file mode 100644 index 000000000..a35b81cd8 --- /dev/null +++ b/app/features/tournament-organization/core/Series.ts @@ -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({ + series, + eventName, +}: { + series: T[]; + eventName: string; +}) { + const eventNameLower = eventName.toLowerCase(); + + return series.find((oneSeries) => + oneSeries.substringMatches.some((substringMatch) => + eventNameLower.includes(substringMatch.toLowerCase()), + ), + ); +} diff --git a/app/features/tournament-organization/core/tentativeTiers.server.ts b/app/features/tournament-organization/core/tentativeTiers.server.ts index 70674e4e1..8bb0c7fe7 100644 --- a/app/features/tournament-organization/core/tentativeTiers.server.ts +++ b/app/features/tournament-organization/core/tentativeTiers.server.ts @@ -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; } diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts index ff804aa23..bda15f828 100644 --- a/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts @@ -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, + }); +} diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 28e216a46..ee37797d4 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -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 ) : null} @@ -398,3 +410,22 @@ function SetInfo({ ); } + +/** 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, + }; +} diff --git a/changelog/2026-08-29-run-image-series-titles.md b/changelog/2026-08-29-run-image-series-titles.md new file mode 100644 index 000000000..2e6209e03 --- /dev/null +++ b/changelog/2026-08-29-run-image-series-titles.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: feature +--- +Winning a tournament shows your titles in that event's series on the exported run image