Derive tournament 1v1/2v2/3v3 tags from team size

This commit is contained in:
Kalle
2026-10-21 19:51:53 +03:00
parent 2bef9f93dd
commit 3058b293df
8 changed files with 148 additions and 36 deletions

View File

@@ -10,7 +10,11 @@ import * as R from "remeda";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type { TeamPickSettings, TournamentSettings } from "~/db/tables-json";
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
import {
CALENDAR_EVENT,
EXCLUDED_TAGS,
} from "~/features/calendar/calendar-constants";
import * as CalendarEvent from "~/features/calendar/core/CalendarEvent";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as TeamPick from "~/features/tournament/core/TeamPick";
import * as Progression from "~/features/tournament-bracket/core/Progression";
@@ -41,7 +45,10 @@ import {
normalizedTeamCount,
tournamentIsRanked,
} from "../tournament/tournament-utils";
import type { CalendarEvent, CalendarEventTag } from "./calendar-types";
import type {
CalendarEventTag,
CalendarEvent as CalendarEventType,
} from "./calendar-types";
import { calendarEventSorter } from "./calendar-utils";
const RECENT_TOURNAMENTS_SHOWN = 10;
@@ -202,18 +209,38 @@ function findAllBetweenTwoTimestampsQuery({
.execute();
}
// virtual tags: leagues and team sizes of tournaments are told apart by their settings, not by anything the organizer picks
function calendarEventTags({
tags,
tournamentSettings,
}: Pick<
Awaited<ReturnType<typeof findAllBetweenTwoTimestampsQuery>>[number],
"tags" | "tournamentSettings"
>): Array<CalendarEventTag> {
const versusTag = tournamentSettings
? CalendarEvent.versusTag(tournamentSettings.minMembersPerTeam ?? 4)
: null;
const withVersusTag = versusTag
? [...(tags ?? []), versusTag].toSorted(
(a, b) =>
CALENDAR_EVENT.TAGS.indexOf(a) - CALENDAR_EVENT.TAGS.indexOf(b),
)
: (tags ?? []);
return tournamentSettings?.isLeague
? ["LEAGUE", ...withVersusTag]
: withVersusTag;
}
function findAllBetweenTwoTimestampsMapped(
rows: Awaited<ReturnType<typeof findAllBetweenTwoTimestampsQuery>>,
): Array<{
at: number;
events: Array<CalendarEvent>;
events: Array<CalendarEventType>;
}> {
const mapped: Array<CalendarEvent & { startsAt: number }> = rows.map(
const mapped: Array<CalendarEventType & { startsAt: number }> = rows.map(
(row) => {
// a virtual tag: leagues are told apart by their setting, not by anything the organizer picks
const tags: Array<CalendarEventTag> = row.tournamentSettings?.isLeague
? ["LEAGUE", ...(row.tags ?? [])]
: (row.tags ?? []);
const tags = calendarEventTags(row);
const isPastEvent =
databaseTimestampToDate(row.startsAt) < sub(new Date(), { days: 1 });

View File

@@ -31,6 +31,8 @@ import {
teamPickSettingsFromFormValues,
} from "../calendar-new-schemas";
import { formValuesToInputBrackets } from "../calendar-progression-form";
import type { CalendarEventTag } from "../calendar-types";
import * as CalendarEvent from "../core/CalendarEvent";
import { findValidOrganizations } from "../loaders/calendar.new.server";
export const action = defineAction(
@@ -119,13 +121,11 @@ export const action = defineAction(
discordInviteCode: data.discordInviteCode
? pathnameFromPotentialURL(data.discordInviteCode)
: data.discordInviteCode,
tags:
data.tags.length > 0
? data.tags.toSorted(
(a, b) =>
CALENDAR_EVENT.TAGS.indexOf(a) - CALENDAR_EVENT.TAGS.indexOf(b),
)
: null,
tags: sortedTagsOrNull(
isAddingTournament
? data.tags.filter((tag) => !CalendarEvent.isVersusTag(tag))
: data.tags,
),
badges: data.trophyId
? []
: data.badges.filter((badge) => attachableBadgeIds.has(badge)),
@@ -223,6 +223,14 @@ export const action = defineAction(
},
);
function sortedTagsOrNull(tags: Array<CalendarEventTag>) {
if (tags.length === 0) return null;
return tags.toSorted(
(a, b) => CALENDAR_EVENT.TAGS.indexOf(a) - CALENDAR_EVENT.TAGS.indexOf(b),
);
}
/** Bracket progression from the `brackets` + `progression` fields, already validated by the schema's refine. */
function bracketProgressionFromFormData(data: {
toToolsEnabled: boolean;

View File

@@ -32,7 +32,20 @@ import {
progressionFormField,
validateBracketProgressionFormValues,
} from "./calendar-progression-form";
import type { CalendarEventTag } from "./calendar-types";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
import * as CalendarEvent from "./core/CalendarEvent";
// the league tag is derived from the league setting, never picked by hand
const PICKABLE_TAGS = CALENDAR_EVENT.TAGS.filter((tag) => tag !== "LEAGUE");
/** The `tags` field as rendered for tournaments, their team size tag is derived from `minMembersPerTeam` instead. */
export const tournamentTagsField = checkboxGroup({
label: "labels.tags",
items: tagItems(
PICKABLE_TAGS.filter((tag) => !CalendarEvent.isVersusTag(tag)),
),
});
/** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */
const calendarEventDateField = datetime({
@@ -126,11 +139,7 @@ export const calendarNewBaseSchema = v.object({
}),
tags: checkboxGroup({
label: "labels.tags",
// derived from the league setting, never picked by hand
items: CALENDAR_EVENT.TAGS.filter((tag) => tag !== "LEAGUE").map((tag) => ({
value: tag,
label: `options.tag.${tag}` as const,
})),
items: tagItems(PICKABLE_TAGS),
}),
badges: badges({ label: "labels.badges", maxCount: 50 }),
trophyId: customField({ initialValue: null }, v.nullish(id)),
@@ -266,6 +275,13 @@ export const TOURNAMENT_FORM_STEPS: ReadonlyArray<
},
];
function tagItems<T extends CalendarEventTag>(tags: Array<T>) {
return tags.map((tag) => ({
value: tag,
label: `options.tag.${tag}` as const,
}));
}
function calendarNewSyncRefine(
data: v.InferOutput<typeof calendarNewBaseSchema>,
ctx: ValidationCtx,

View File

@@ -147,6 +147,24 @@ describe("CalendarEvent.applyFilters", () => {
expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]);
});
test("filters by preferredVersus using a tournament's team size when it has no versus tag", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tags: [], minMembersPerTeam: 2 }),
makeEvent({ id: 2, tags: [], minMembersPerTeam: 4 }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
preferredVersus: ["2v2"],
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
});
test("filters by modes (not exact)", () => {
const events = [
{

View File

@@ -9,10 +9,19 @@ import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import { assertType } from "~/utils/types";
import type {
CalendarEvent,
CalendarEventTag,
CalendarFilters,
GroupedCalendarEvents,
} from "../calendar-types";
const MEMBERS_PER_TEAM_BY_VERSUS_TAG = {
ONES: 1,
DUOS: 2,
TRIOS: 3,
} as const satisfies Partial<Record<CalendarEventTag, number>>;
type VersusTag = keyof typeof MEMBERS_PER_TEAM_BY_VERSUS_TAG;
export const FILTERS_KEYS = [
"preferredStartTime",
"tagsIncluded",
@@ -34,6 +43,22 @@ export const FILTERS_KEYS = [
assertType<(typeof FILTERS_KEYS)[number], keyof CalendarFilters>();
assertType<keyof CalendarFilters, (typeof FILTERS_KEYS)[number]>();
/** Whether the tag tells the team size (1v1, 2v2 or 3v3). Tournaments never store these, their tag is derived from `minMembersPerTeam` via {@link versusTag}. */
export function isVersusTag(tag: CalendarEventTag): tag is VersusTag {
return Object.hasOwn(MEMBERS_PER_TEAM_BY_VERSUS_TAG, tag);
}
/** The tag telling the team size of a tournament with the given minimum members per team, null for 4v4. */
export function versusTag(minMembersPerTeam: number): VersusTag | null {
for (const [tag, membersPerTeam] of Object.entries(
MEMBERS_PER_TEAM_BY_VERSUS_TAG,
)) {
if (membersPerTeam === minMembersPerTeam) return tag as VersusTag;
}
return null;
}
/** Default empty calendar filters. */
export function defaultFilters(): CalendarFilters {
return {
@@ -200,23 +225,14 @@ function matchesFilter(
return true;
}
const eventType = event.tags.find(
(tag) => tag === "ONES" || tag === "DUOS" || tag === "TRIOS",
);
const tag = event.tags.find(isVersusTag);
const membersPerTeam = tag
? MEMBERS_PER_TEAM_BY_VERSUS_TAG[tag]
: event.minMembersPerTeam;
if (eventType === "ONES") {
return preferredVersus.includes("1v1");
}
const versus = `${membersPerTeam}v${membersPerTeam}`;
if (eventType === "DUOS") {
return preferredVersus.includes("2v2");
}
if (eventType === "TRIOS") {
return preferredVersus.includes("3v3");
}
return preferredVersus.includes("4v4");
return preferredVersus.some((preferred) => preferred === versus);
}
case "modes": {
const modes = filters[key];

View File

@@ -56,6 +56,7 @@ import {
type TeamPickCountsFormValue,
TOURNAMENT_FORM_STEPS,
teamPickSettingsFromFormValues,
tournamentTagsField,
} from "../calendar-new-schemas";
import {
type BracketFormValue,
@@ -403,7 +404,7 @@ function TournamentSteps() {
<FormField name="rules" />
<FormField name="avatarImgId" />
<FormField name="discordInviteCode" />
<FormField name="tags" />
<FormField name="tags" field={tournamentTagsField} />
</div>
</FormStep>

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: feature
---
Tournament 1v1, 2v2 and 3v3 tags now follow the picked team size automatically

View File

@@ -0,0 +1,21 @@
import { type Kysely, sql } from "kysely";
/** A tournament's 1v1, 2v2 and 3v3 tags are now derived from its `minMembersPerTeam` setting, this clears the stored ones. */
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await sql`
update "CalendarEvent"
set "tags" = (
select case when count(*) = 0 then null else json_group_array("value") end
from json_each("CalendarEvent"."tags")
where "value" not in ('ONES', 'DUOS', 'TRIOS')
)
where "tournamentId" is not null
and exists (
select 1
from json_each("CalendarEvent"."tags")
where "value" in ('ONES', 'DUOS', 'TRIOS')
)
`.execute(trx);
});
}