From 0c257a9ffa317e56bf13fd92c81359c65da5a673 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:09:25 +0300 Subject: [PATCH] Better calendar event template select contents --- .../CalendarRepository.server.test.ts | 190 ++++++++++++++++++ .../calendar/CalendarRepository.server.ts | 91 ++++++++- .../calendar/loaders/calendar.new.server.ts | 4 +- ...TournamentOrganizationRepository.server.ts | 10 +- .../loaders/to.$id.teams.$tid.comps.server.ts | 4 +- changelog/2026-09-01-tournament-templates.md | 8 + scripts/benchmark-db/cases.ts | 5 +- 7 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 app/features/calendar/CalendarRepository.server.test.ts create mode 100644 changelog/2026-09-01-tournament-templates.md diff --git a/app/features/calendar/CalendarRepository.server.test.ts b/app/features/calendar/CalendarRepository.server.test.ts new file mode 100644 index 000000000..3bd4e85a5 --- /dev/null +++ b/app/features/calendar/CalendarRepository.server.test.ts @@ -0,0 +1,190 @@ +import { sub } from "date-fns"; +import { beforeEach, describe, expect, test } from "vitest"; +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 * as CalendarRepository from "./CalendarRepository.server"; + +const users = UserFactory.pool(); + +describe("findRecentTournamentsByOrganizerUserId", () => { + /** As many events as the dropdown has spots, so nothing is filled in. */ + const SPOTS_SHOWN = 10; + + const organizerId = () => users.id(1); + + beforeEach(async () => { + await users.create(1); + }); + + const daysAgo = (days: number) => + dateToDatabaseTimestamp(sub(new Date(), { days })); + + const seedOrganizationWithSeries = (seriesNames: string[]) => + TournamentOrganizationFactory.create( + { ownerId: organizerId() }, + { + series: seriesNames.map((name) => ({ + name, + description: null, + showLeaderboard: false, + })), + }, + ); + + const seedTournament = ({ + name, + startedDaysAgo, + organizationId = null, + }: { + name: string; + startedDaysAgo: number; + organizationId?: number | null; + }) => + TournamentFactory.create({ + authorId: organizerId(), + organizationId, + name, + startTimes: [daysAgo(startedDaysAgo)], + }); + + /** Events of no series, newest first, starting from the given day. */ + const seedStandaloneTournaments = async ({ + count, + organizationId, + oldestStartedDaysAgo, + }: { + count: number; + organizationId: number; + oldestStartedDaysAgo: number; + }) => { + for (let index = 0; index < count; index++) { + await seedTournament({ + name: `In The Zone ${index}`, + startedDaysAgo: oldestStartedDaysAgo - index, + organizationId, + }); + } + }; + + const recentTournamentNames = async () => { + const tournaments = + await CalendarRepository.findRecentTournamentsByOrganizerUserId( + organizerId(), + ); + + return tournaments.map((tournament) => tournament.name); + }; + + test("drops the older edition of a series when there are more events than spots", async () => { + const org = await seedOrganizationWithSeries(["Low Ink"]); + + await seedTournament({ + name: "Low Ink February", + startedDaysAgo: 1, + organizationId: org.id, + }); + await seedTournament({ + name: "Low Ink January", + startedDaysAgo: 2, + organizationId: org.id, + }); + await seedStandaloneTournaments({ + count: SPOTS_SHOWN - 1, + organizationId: org.id, + oldestStartedDaysAgo: 11, + }); + + const names = await recentTournamentNames(); + + expect(names).toHaveLength(SPOTS_SHOWN); + expect(names).toContain("Low Ink February"); + expect(names).not.toContain("Low Ink January"); + }); + + test("keeps the latest edition of an older series over the older edition of a newer one", async () => { + const org = await seedOrganizationWithSeries(["Low Ink", "Paddling Pool"]); + + await seedTournament({ + name: "Low Ink February", + startedDaysAgo: 1, + organizationId: org.id, + }); + await seedTournament({ + name: "Low Ink January", + startedDaysAgo: 2, + organizationId: org.id, + }); + await seedStandaloneTournaments({ + count: SPOTS_SHOWN - 2, + organizationId: org.id, + oldestStartedDaysAgo: 11, + }); + await seedTournament({ + name: "Paddling Pool October", + startedDaysAgo: 100, + organizationId: org.id, + }); + + const names = await recentTournamentNames(); + + expect(names).toContain("Paddling Pool October"); + expect(names).not.toContain("Low Ink January"); + }); + + test("fills the remaining spots with older editions of a series", async () => { + const org = await seedOrganizationWithSeries(["Low Ink"]); + + await seedTournament({ + name: "Low Ink January", + startedDaysAgo: 2, + organizationId: org.id, + }); + await seedTournament({ + name: "Low Ink February", + startedDaysAgo: 1, + organizationId: org.id, + }); + + expect(await recentTournamentNames()).toEqual([ + "Low Ink February", + "Low Ink January", + ]); + }); + + test("keeps every event of an organization whose names match no series", async () => { + const org = await seedOrganizationWithSeries(["Low Ink"]); + + await seedStandaloneTournaments({ + count: SPOTS_SHOWN + 1, + organizationId: org.id, + oldestStartedDaysAgo: 11, + }); + + expect(await recentTournamentNames()).toHaveLength(SPOTS_SHOWN); + }); + + test("excludes events that started over a year ago", async () => { + const org = await seedOrganizationWithSeries(["Low Ink"]); + + await seedTournament({ + name: "Low Ink February", + startedDaysAgo: 1, + organizationId: org.id, + }); + await seedTournament({ + name: "Low Ink January", + startedDaysAgo: 400, + organizationId: org.id, + }); + + expect(await recentTournamentNames()).toEqual(["Low Ink February"]); + }); + + test("includes events that belong to no organization", async () => { + await seedTournament({ name: "Low Ink February", startedDaysAgo: 1 }); + + expect(await recentTournamentNames()).toEqual(["Low Ink February"]); + }); +}); diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 6bbea3542..32f8eeedd 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -13,7 +13,9 @@ import type { TournamentSettings } from "~/db/tables-json"; import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants"; import * as ChatRepository from "~/features/chat/ChatRepository.server"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import * as Series from "~/features/tournament-organization/core/Series"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; +import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import { databaseTimestampNow, databaseTimestampToDate, @@ -39,6 +41,8 @@ import { import type { CalendarEvent } from "./calendar-types"; import { calendarEventSorter } from "./calendar-utils"; +const RECENT_TOURNAMENTS_SHOWN = 10; + function hasBadge(eb: ExpressionBuilder) { return eb .exists( @@ -355,8 +359,14 @@ export async function findById( }; } -export async function findRecentTournamentsByAuthorId(authorId: number) { - return db +/** + * Tournaments from the past year organized by the user (as the author, via an + * organization ADMIN/ORGANIZER role or as tournament staff ORGANIZER), newest first. + * Only the latest event of each tournament series is included, unless there are fewer + * series than spots to show, in which case the next newest events fill the rest. + */ +export async function findRecentTournamentsByOrganizerUserId(userId: number) { + const tournaments = await db .selectFrom("CalendarEvent") .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId") .innerJoin( @@ -364,15 +374,82 @@ export async function findRecentTournamentsByAuthorId(authorId: number) { "CalendarEvent.id", "CalendarEventDate.eventId", ) - .select([ + .select(({ fn }) => [ "CalendarEvent.id", "CalendarEvent.name", - "CalendarEventDate.startsAt", + "CalendarEvent.organizationId", + fn.min("CalendarEventDate.startsAt").as("startsAt"), ]) - .where("CalendarEvent.authorId", "=", authorId) - .orderBy("CalendarEvent.id", "desc") - .limit(10) + .where((eb) => + eb.or([ + eb("CalendarEvent.authorId", "=", userId), + eb.exists( + eb + .selectFrom("TournamentOrganizationMember") + .select("TournamentOrganizationMember.userId") + .whereRef( + "TournamentOrganizationMember.organizationId", + "=", + "CalendarEvent.organizationId", + ) + .where("TournamentOrganizationMember.userId", "=", userId) + .where("TournamentOrganizationMember.role", "in", [ + "ADMIN", + "ORGANIZER", + ]), + ), + eb.exists( + eb + .selectFrom("TournamentStaff") + .select("TournamentStaff.userId") + .whereRef( + "TournamentStaff.tournamentId", + "=", + "CalendarEvent.tournamentId", + ) + .where("TournamentStaff.userId", "=", userId) + .where("TournamentStaff.role", "=", "ORGANIZER"), + ), + ]), + ) + .where( + "CalendarEventDate.startsAt", + ">=", + dateToDatabaseTimestamp(sub(new Date(), { years: 1 })), + ) + .groupBy("CalendarEvent.id") + .orderBy("startsAt", "desc") .execute(); + + const series = + await TournamentOrganizationRepository.findAllSeriesByOrganizationIds( + R.unique( + tournaments + .map((tournament) => tournament.organizationId) + .filter((organizationId) => organizationId !== null), + ), + ); + + const latestOfEachSeries = R.uniqueBy(tournaments, (tournament) => { + const tournamentSeries = Series.findByEventName({ + series: series.filter( + (oneSeries) => oneSeries.organizationId === tournament.organizationId, + ), + eventName: tournament.name, + }); + + return tournamentSeries + ? `series-${tournamentSeries.id}` + : `event-${tournament.id}`; + }); + + return R.sortBy( + R.take( + R.unique([...latestOfEachSeries, ...tournaments]), + RECENT_TOURNAMENTS_SHOWN, + ), + [(tournament) => tournament.startsAt, "desc"], + ); } export async function findResultsByEventId(eventId: number) { diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index f86ae75eb..247a379ab 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -128,7 +128,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { eventToCopy, recentTournaments: canAddTournaments && !eventToEdit - ? await CalendarRepository.findRecentTournamentsByAuthorId(user.id) + ? await CalendarRepository.findRecentTournamentsByOrganizerUserId( + user.id, + ) : undefined, organizations, trophies, diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 2fbcde250..dd8b96e76 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -516,15 +516,21 @@ export async function findAllEventsBySeries({ return events.map(mapEvent); } -export function findAllSeriesByOrganizationId(organizationId: number) { +/** Series belonging to any of the given organizations. */ +export async function findAllSeriesByOrganizationIds( + organizationIds: number[], +) { + if (organizationIds.length === 0) return []; + return db .selectFrom("TournamentOrganizationSeries") .select([ "TournamentOrganizationSeries.id", "TournamentOrganizationSeries.name", + "TournamentOrganizationSeries.organizationId", "TournamentOrganizationSeries.substringMatches", ]) - .where("TournamentOrganizationSeries.organizationId", "=", organizationId) + .where("TournamentOrganizationSeries.organizationId", "in", organizationIds) .execute(); } 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 bda15f828..277833a49 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 @@ -128,9 +128,9 @@ async function previousSeriesWins({ const series = Series.findByEventName({ series: - await TournamentOrganizationRepository.findAllSeriesByOrganizationId( + await TournamentOrganizationRepository.findAllSeriesByOrganizationIds([ organizationId, - ), + ]), eventName: tournamentName, }); if (!series) return null; diff --git a/changelog/2026-09-01-tournament-templates.md b/changelog/2026-09-01-tournament-templates.md new file mode 100644 index 000000000..b167fc48a --- /dev/null +++ b/changelog/2026-09-01-tournament-templates.md @@ -0,0 +1,8 @@ +--- +navItem: calendar +type: feature +--- +Better tournament templates when adding a new tournament + +- Tournaments run by your organization or those you were added as staff can now be used as templates, not only the ones you added yourself +- Only the latest event of each tournament series is shown, fitting more different templates in the list diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 54e184c75..62a39ea17 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -283,9 +283,10 @@ export function buildCases(fx: Fixtures): { }), ); add( - "CalendarRepository.findRecentTournamentsByAuthorId", + "CalendarRepository.findRecentTournamentsByOrganizerUserId", fx.calendarAuthorId, - (authorId) => CalendarRepository.findRecentTournamentsByAuthorId(authorId), + (authorId) => + CalendarRepository.findRecentTournamentsByOrganizerUserId(authorId), ); add("CalendarRepository.findResultsByEventId", fx.resultsEventId, (eventId) => CalendarRepository.findResultsByEventId(eventId),