Better calendar event template select contents
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-09-01 19:09:25 +03:00
parent 5e6b282b2f
commit 0c257a9ffa
7 changed files with 298 additions and 14 deletions

View File

@@ -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"]);
});
});

View File

@@ -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<DB, "CalendarEventDate">) {
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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),