diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index e3d2f84e7..c40d552de 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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>[number], + "tags" | "tournamentSettings" +>): Array { + 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>, ): Array<{ at: number; - events: Array; + events: Array; }> { - const mapped: Array = rows.map( + const mapped: Array = rows.map( (row) => { - // a virtual tag: leagues are told apart by their setting, not by anything the organizer picks - const tags: Array = row.tournamentSettings?.isLeague - ? ["LEAGUE", ...(row.tags ?? [])] - : (row.tags ?? []); + const tags = calendarEventTags(row); const isPastEvent = databaseTimestampToDate(row.startsAt) < sub(new Date(), { days: 1 }); diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 7d0b9aa84..5f685ffc8 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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) { + 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; diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index 32033e463..d705453be 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -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(tags: Array) { + return tags.map((tag) => ({ + value: tag, + label: `options.tag.${tag}` as const, + })); +} + function calendarNewSyncRefine( data: v.InferOutput, ctx: ValidationCtx, diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts index 2f149dc3d..2dde370cf 100644 --- a/app/features/calendar/core/CalendarEvent.test.ts +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -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 = [ { diff --git a/app/features/calendar/core/CalendarEvent.ts b/app/features/calendar/core/CalendarEvent.ts index dab33ddb8..9dbfd9c29 100644 --- a/app/features/calendar/core/CalendarEvent.ts +++ b/app/features/calendar/core/CalendarEvent.ts @@ -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>; + +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(); +/** 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]; diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 05a68b123..4cb5af7dc 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -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() { - + diff --git a/changelog/2026-10-21-tournament-versus-tags.md b/changelog/2026-10-21-tournament-versus-tags.md new file mode 100644 index 000000000..285b4aea0 --- /dev/null +++ b/changelog/2026-10-21-tournament-versus-tags.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: feature +--- +Tournament 1v1, 2v2 and 3v3 tags now follow the picked team size automatically diff --git a/migrations/20261021070535-derive-tournament-versus-tags.ts b/migrations/20261021070535-derive-tournament-versus-tags.ts new file mode 100644 index 000000000..3ba841298 --- /dev/null +++ b/migrations/20261021070535-derive-tournament-versus-tags.ts @@ -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): Promise { + 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); + }); +}