diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index 309299ae0..535a3c15a 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -302,10 +302,18 @@ export const getContestNameLabel = (contestId: string) => { return 'TDPC'; } + if (contestId.startsWith('past')) { + return getPastContestLabel(PAST_TRANSLATIONS, contestId); + } + if (contestId === 'practice2') { return 'ACL Practice'; } + if (contestId.startsWith('joi')) { + return getJoiContestLabel(contestId); + } + if (contestId === 'tessoku-book') { return '競技プログラミングの鉄則'; } @@ -324,20 +332,203 @@ export const getContestNameLabel = (contestId: string) => { // AIZU ONLINE JUDGE if (aojCoursePrefixes.has(contestId)) { - return 'AOJ Courses'; + return getAojContestLabel(AOJ_COURSES, contestId); } if (contestId.startsWith('PCK')) { - return getAojChallengeLabel(PCK_TRANSLATIONS, contestId); + return getAojContestLabel(PCK_TRANSLATIONS, contestId); } if (contestId.startsWith('JAG')) { - return getAojChallengeLabel(JAG_TRANSLATIONS, contestId); + return getAojContestLabel(JAG_TRANSLATIONS, contestId); } return contestId.toUpperCase(); }; +/** + * A mapping of contest dates to their respective Japanese translations. + * Each key represents a date in the format 'YYYYMM', and the corresponding value + * is the Japanese translation indicating the contest number. + * + * Note: + * After the 15th contest, the URL includes the number of times the contest has been held + * + * See: + * https://atcoder.jp/contests/archive?ratedType=0&category=50 + * + * Example: + * - '201912': ' 第 1 回' (The 1st contest in December 2019) + * - '202303': ' 第 14 回' (The 14th contest in March 2023) + */ +export const PAST_TRANSLATIONS = { + '201912': ' 第 1 回', + '202004': ' 第 2 回', + '202005': ' 第 3 回', + '202010': ' 第 4 回', + '202012': ' 第 5 回', + '202104': ' 第 6 回', + '202107': ' 第 7 回', + '202109': ' 第 8 回', + '202112': ' 第 9 回', + '202203': ' 第 10 回', + '202206': ' 第 11 回', + '202209': ' 第 12 回', + '202212': ' 第 13 回', + '202303': ' 第 14 回', +}; + +/** + * A regular expression to match strings that representing the 15th or later PAST contests. + * The string should start with "past" followed by exactly two digits and end with "-open". + * The matching is case-insensitive. + * + * Examples: + * - "past15-open" (matches) + * - "past16-open" (matches) + * - "past99-open" (matches) + */ +const regexForPast = /^past(\d+)-open$/i; + +export function getPastContestLabel( + translations: Readonly, + contestId: string, +): string { + let label = contestId; + + Object.entries(translations).forEach(([abbrEnglish, japanese]) => { + label = label.replace(abbrEnglish, japanese); + }); + + if (label == contestId) { + label = label.replace(regexForPast, (_, round) => { + return `PAST 第 ${round} 回`; + }); + } + + // Remove suffix + return label.replace('-open', '').toUpperCase(); +} + +/** + * Regular expression to match specific patterns in contest identifiers. + * + * The pattern matches strings that follow these rules: + * - Starts with "joi" (case insensitive). + * - Optionally followed by "g" or "open". + * - Optionally represents year (4-digit number). + * - Optionally followed by "yo", "ho", "sc", or "sp" (Qual, Final and Spring camp). + * - Optionally represents year (4-digit number). + * - Optionally followed by "1" or "2" (Qual 1st, 2nd). + * - Optionally followed by "a", "b", or "c" (Round 1, 2 and 3). + * + * Flags: + * - `i`: Case insensitive matching. + * + * Examples: + * - "joi2024yo1a" (matches) + * - "joi2023ho" (matches) + * - "joisc2022" (matches) + * - "joisp2021" (matches) + * - "joig2024-open" (matches) + * - "joisc2024" (matches) + * - "joisp2022" (matches) + * - "joi24yo3d" (does not match) + */ +const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp)*(\d{4})*(1|2)*(a|b|c)*/i; + +/** + * Transforms a contest ID into a formatted contest label. + * + * This function processes the given contest ID by removing specific suffixes + * and applying various transformations to generate a human-readable contest label. + * + * @param contestId - The ID of the contest to be transformed. + * @returns The formatted contest label. + */ +export function getJoiContestLabel(contestId: string): string { + let label = contestId; + // Remove suffix + label = label.replace('-open', ''); + + label = label.replace( + regexForJoi, + (_, base, subType, yearPrefix, division, yearSuffix, qual, qualRound) => { + const SPACE = ' '; + + let newLabel = base.toUpperCase(); + newLabel += addJoiSubTypeIfNeeds(subType); + + if (division !== undefined) { + newLabel += SPACE; + newLabel += addJoiDivisionNameIfNeeds(division, qual); + } + + newLabel += SPACE; + newLabel += addJoiYear(yearSuffix, yearPrefix); + + if (qualRound !== undefined) { + newLabel += SPACE; + newLabel += addJoiQualRoundNameIfNeeds(qualRound); + } + + return newLabel; + }, + ); + + return label; +} + +function addJoiSubTypeIfNeeds(subType: string): string { + if (subType === 'g') { + return subType.toUpperCase(); + } else if (subType === 'open') { + return ' Open'; + } + + return ''; +} + +function addJoiDivisionNameIfNeeds(division: string, qual: string): string { + if (division === 'yo') { + if (qual === undefined) { + return '予選'; + } else if (qual === '1') { + return '一次予選'; + } else if (qual === '2') { + return '二次予選'; + } + } else if (division === 'ho') { + return '本選'; + } else if (division === 'sc' || division === 'sp') { + return '春合宿'; + } + + return ''; +} + +function addJoiYear(yearSuffix: string, yearPrefix: string): string { + if (yearPrefix !== undefined) { + return yearPrefix; + } else if (yearSuffix !== undefined) { + return yearSuffix; + } + + return ''; +} + +function addJoiQualRoundNameIfNeeds(qualRound: string): string { + if (qualRound === 'a') { + return '第 1 回'; + } else if (qualRound === 'b') { + return '第 2 回'; + } else if (qualRound === 'c') { + return '第 3 回'; + } + + return ''; +} + /** * Generates a formatted contest label for AtCoder University contests. * @@ -349,6 +540,10 @@ export const getContestNameLabel = (contestId: string) => { * @returns The formatted contest label (ex: UTPC 2023). */ export function getAtCoderUniversityContestLabel(contestId: string): string { + if (!regexForAtCoderUniversity.test(contestId)) { + throw new Error(`Invalid university contest ID format: ${contestId}`); + } + return contestId.replace( regexForAtCoderUniversity, (_, contestType, common, contestYear) => @@ -386,7 +581,7 @@ const JAG_TRANSLATIONS = { Regional: ' 模擬地区 ', }; -function getAojChallengeLabel( +export function getAojContestLabel( translations: Readonly, contestId: string, ): string { @@ -410,5 +605,7 @@ export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: str }; function isAojContest(contestId: string): boolean { - return contestId.startsWith('PCK') || contestId.startsWith('JAG'); + return ( + aojCoursePrefixes.has(contestId) || contestId.startsWith('PCK') || contestId.startsWith('JAG') + ); } diff --git a/src/test/lib/utils/contest.test.ts b/src/test/lib/utils/contest.test.ts index 530908967..03ac79bce 100644 --- a/src/test/lib/utils/contest.test.ts +++ b/src/test/lib/utils/contest.test.ts @@ -13,6 +13,7 @@ import { contestTypePriorities, getContestNameLabel, addContestNameToTaskIndex, + getAtCoderUniversityContestLabel, } from '$lib/utils/contest'; describe('Contest', () => { @@ -366,18 +367,6 @@ describe('Contest', () => { }); }); - // TODO(#issue): Skipped until notational inconsistencies are resolved. - // Current issues: - // 1. Contest names use inconsistent formats (e.g., "past201912-open" vs "past17-open") - // 2. Need to standardize naming conventions across all contests - describe.skip('when contest_id contains past', () => { - TestCasesForContestNameLabel.past.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - describe('when contest_id is practice2 (ACL practice)', () => { TestCasesForContestNameLabel.aclPractice.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { @@ -386,18 +375,6 @@ describe('Contest', () => { }); }); - // TODO(#issue): Skipped until notational inconsistencies are resolved. - // Current issues: - // 1. Contest names use inconsistent formats - // 2. Need to standardize naming conventions across all contests - describe.skip('when contest_id contains joi', () => { - TestCasesForContestNameLabel.joi.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - describe('when contest_id contains chokudai_S', () => { TestCasesForContestNameLabel.atCoderOthers.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { @@ -446,6 +423,30 @@ describe('Contest', () => { }); }); + describe('when contest_id contains past', () => { + TestCasesForContestNameAndTaskIndex.past.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id contains joi', () => { + TestCasesForContestNameAndTaskIndex.joi.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + describe('when contest_id is tessoku-book', () => { TestCasesForContestNameAndTaskIndex.tessokuBook.forEach(({ name, value }) => { runTests( @@ -545,4 +546,23 @@ describe('Contest', () => { }); }); }); + + describe('get AtCoder university contest label', () => { + describe('expected to return correct label for valid format', () => { + test.each([ + ['utpc2019', 'UTPC 2019'], + ['ttpc2022', 'TTPC 2022'], + ])('when %s is given', (input, expected) => { + expect(getAtCoderUniversityContestLabel(input)).toBe(expected); + }); + }); + + describe('expected to be thrown an error if an invalid format is given', () => { + test.each(['utpc24', 'ttpc', 'tupc'])('when %s is given', (input) => { + expect(() => getAtCoderUniversityContestLabel(input)).toThrow( + `Invalid university contest ID format: ${input}`, + ); + }); + }); + }); }); diff --git a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts index 267f3f7fd..fe46a93d7 100644 --- a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts +++ b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts @@ -1,5 +1,12 @@ import { createTestCase, zip } from '../../common/test_helpers'; -import { getAtCoderUniversityContestLabel } from '$lib/utils/contest'; +import { + getPastContestLabel, + getJoiContestLabel, + getAtCoderUniversityContestLabel, + getAojContestLabel, + PAST_TRANSLATIONS, + AOJ_COURSES, +} from '$lib/utils/contest'; export type TestCaseForContestNameAndTaskIndex = { contestId: string; @@ -77,6 +84,189 @@ export const typical90 = [ }), ]; +const generatePastTestCases = ( + contestIds: string[], + taskIndices: string[], +): { name: string; value: TestCaseForContestNameAndTaskIndex }[] => { + return zip(contestIds, taskIndices).map(([contestId, taskIndex]) => { + const testCase = createTestCaseForContestNameAndTaskIndex(`PAST, ${contestId} ${taskIndex}`)({ + contestId: `${contestId}`, + taskTableIndex: `${taskIndex}`, + expected: `${getPastContestLabel(PAST_TRANSLATIONS, contestId)} - ${taskIndex}`, + }); + + return testCase; + }); +}; + +const PAST_TEST_DATA = { + // 1st + 'past201912-open': { + contestId: 'past201912-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + // 2nd + 'past202004-open': { + contestId: 'past202004-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + // 3rd + 'past202005-open': { + contestId: 'past202005-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + // 9th + 'past202112-open': { + contestId: 'past202112-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + // 10th + 'past202203-open': { + contestId: 'past202203-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + // 14th + 'past202303-open': { + contestId: 'past202303-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + 'past15-open': { + contestId: 'past15-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + 'past16-open': { + contestId: 'past16-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, + 'past17-open': { + contestId: 'past17-open', + tasks: ['A', 'B', 'C', 'M', 'N', 'O'], + }, +}; + +export const past = Object.entries(PAST_TEST_DATA).flatMap(([contestId, tasks]) => + generatePastTestCases(Array(tasks.tasks.length).fill(contestId), tasks.tasks), +); + +const generateJoiTestCases = ( + contestIds: string[], + taskIndices: string[], +): { name: string; value: TestCaseForContestNameAndTaskIndex }[] => { + return zip(contestIds, taskIndices).map(([contestId, taskIndex]) => { + const testCase = createTestCaseForContestNameAndTaskIndex(`JOI, ${contestId} ${taskIndex}`)({ + contestId: `${contestId}`, + taskTableIndex: `${taskIndex}`, + expected: `${getJoiContestLabel(contestId)} - ${taskIndex}`, + }); + + return testCase; + }); +}; + +const JOI_TEST_DATA = { + joi2006yo: { + contestId: 'joi2006yo', + tasks: ['A', 'B', 'C', 'D', 'E'], + }, + joi2007yo: { + contestId: 'joi2007yo', + tasks: ['A', 'B', 'E', 'F'], + }, + joi2018yo: { + contestId: 'joi2018yo', + tasks: ['A', 'B', 'F'], + }, + joi2024yo1c: { + contestId: 'joi2024yo1c', + tasks: ['A', 'B', 'C', 'D'], + }, + joi2025yo1a: { + contestId: 'joi2025yo1a', + tasks: ['A', 'B', 'D'], + }, + joi2025yo1b: { + contestId: 'joi2025yo1b', + tasks: ['A', 'B', 'D'], + }, + joi2023yo2: { + contestId: 'joi2023yo2', + tasks: ['A', 'B', 'C', 'D', 'E'], + }, + joi2024yo2: { + contestId: 'joi2024yo2', + tasks: ['A', 'B', 'E'], + }, + joi2006ho: { + contestId: 'joi2006ho', + tasks: ['A', 'B', 'C', 'D', 'E'], + }, + joi2007ho: { + contestId: 'joi2007ho', + tasks: ['A', 'B', 'E'], + }, + joi2023ho: { + contestId: 'joi2023ho', + tasks: ['A', 'B', 'E'], + }, + joi2024ho: { + contestId: 'joi2024ho', + tasks: ['A', 'B', 'E'], + }, + joisc2007: { + contestId: 'joisc2007', + tasks: ['anagra', 'buildi', 'salt', 'score'], + }, + joisc2008: { + contestId: 'joisc2008', + tasks: ['belt', 'typhoon'], + }, + joisc2022: { + contestId: 'joisc2022', + tasks: ['A', 'B', 'K', 'L'], + }, + joisp2023: { + contestId: 'joisp2023', + tasks: ['A', 'B', 'K', 'L'], + }, + joisp2024: { + contestId: 'joisp2024', + tasks: ['A', 'B', 'K', 'L'], + }, + joiopen2024: { + contestId: 'joiopen2024', + tasks: ['A', 'B', 'C'], + }, + 'joig2021-open': { + contestId: 'joig2021-open', + tasks: ['A', 'B', 'C', 'D', 'E', 'F'], + }, + 'joig2022-open': { + contestId: 'joig2022-open', + tasks: ['A', 'B', 'F'], + }, + 'joig2023-open': { + contestId: 'joig2023-open', + tasks: ['A', 'B', 'F'], + }, + // Note: Contest ID pattern changed from joisc to joisp starting from 2023 + joigsc2022: { + contestId: 'joigsc2022', + tasks: ['A', 'B', 'H'], + }, + joigsp2023: { + contestId: 'joigsp2023', + tasks: ['A', 'B', 'H'], + }, + joigsp2024: { + contestId: 'joigsp2024', + tasks: ['A', 'B', 'H'], + }, +}; + +export const joi = Object.entries(JOI_TEST_DATA).flatMap(([contestId, tasks]) => + generateJoiTestCases(Array(tasks.tasks.length).fill(contestId), tasks.tasks), +); + export const tessokuBook = [ createTestCaseForContestNameAndTaskIndex('Tessoku Book, Task A01')({ contestId: 'tessoku-book', @@ -373,7 +563,7 @@ const generateAojCoursesTestCases = ( )({ contestId: `${contestId}`, taskTableIndex: `${contestId}_${taskIndex}`, - expected: `AOJ Courses - ${contestId}_${taskIndex}`, + expected: `AOJ ${contestId}_${taskIndex}${getAojContestLabel(AOJ_COURSES, contestId)}`, }); return testCase; diff --git a/src/test/lib/utils/test_cases/contest_name_labels.ts b/src/test/lib/utils/test_cases/contest_name_labels.ts index d5008dea9..9c7a8fa97 100644 --- a/src/test/lib/utils/test_cases/contest_name_labels.ts +++ b/src/test/lib/utils/test_cases/contest_name_labels.ts @@ -21,42 +21,6 @@ export const tdpc = [ }), ]; -// Note: Not yet implemented, because notational distortion needs to be corrected for each contest. -export const past = [ - createTestCaseForContestNameLabel('PAST 17th')({ - contestId: 'past17-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 16th')({ - contestId: 'past16-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 15th')({ - contestId: 'past15-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 14th')({ - contestId: 'past202303-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 13th')({ - contestId: 'past202212-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 3rd')({ - contestId: 'past202005-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 2nd')({ - contestId: 'past202004-open', - expected: '', - }), - createTestCaseForContestNameLabel('PAST 1st')({ - contestId: 'past201912-open', - expected: '', - }), -]; - export const aclPractice = [ createTestCaseForContestNameLabel('ACL Practice')({ contestId: 'practice2', @@ -64,54 +28,6 @@ export const aclPractice = [ }), ]; -// Note: Not yet implemented, because notational distortion needs to be corrected for each contest. -export const joi = [ - createTestCaseForContestNameLabel('JOIG 2024 open')({ - contestId: 'joig2024-open', - expected: '', - }), - createTestCaseForContestNameLabel('JOIG 2023 open')({ - contestId: 'joig2023-open', - expected: '', - }), - createTestCaseForContestNameLabel('JOIG 2022 open')({ - contestId: 'joig2022-open', - expected: '', - }), - createTestCaseForContestNameLabel('JOIG 2021 open')({ - contestId: 'joig2021-open', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2024 qual 1A')({ - contestId: 'joi2024yo1a', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2024 qual 1B')({ - contestId: 'joi2024yo1b', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2024 qual 1C')({ - contestId: 'joi2024yo1c', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2023 qual 1A')({ - contestId: 'joi2023yo1a', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2023 qual 1B')({ - contestId: 'joi2023yo1b', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2023 qual 1C')({ - contestId: 'joi2023yo1c', - expected: '', - }), - createTestCaseForContestNameLabel('JOI 2018 qual')({ - contestId: 'joi2018yo', - expected: '', - }), -]; - export const atCoderOthers = [ createTestCaseForContestNameLabel('Chokudai SpeedRun 001')({ contestId: 'chokudai_S001', diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index 2095c4516..c4fd57fd2 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -88,17 +88,50 @@ export const aclPractice = [ ]; const joiContestData = [ - { name: 'JOIG 2024 open', contestId: 'joig2024-open' }, - { name: 'JOIG 2023 open', contestId: 'joig2023-open' }, - { name: 'JOIG 2022 open', contestId: 'joig2022-open' }, - { name: 'JOIG 2021 open', contestId: 'joig2021-open' }, + // Historical JOI Qualifiers (2006-2007) + { name: 'JOI 2006 qual', contestId: 'joi2006yo' }, + { name: 'JOI 2007 qual', contestId: 'joi2007yo' }, + { name: 'JOI 2018 qual', contestId: 'joi2018yo' }, + { name: 'JOI 2019 qual', contestId: 'joi2019yo' }, + // Recent JOI Qualifiers (2024-) { name: 'JOI 2024 qual 1A', contestId: 'joi2024yo1a' }, { name: 'JOI 2024 qual 1B', contestId: 'joi2024yo1b' }, { name: 'JOI 2024 qual 1C', contestId: 'joi2024yo1c' }, - { name: 'JOI 2023 qual 1A', contestId: 'joi2023yo1a' }, - { name: 'JOI 2023 qual 1B', contestId: 'joi2023yo1b' }, - { name: 'JOI 2023 qual 1C', contestId: 'joi2023yo1c' }, - { name: 'JOI 2018 qual', contestId: 'joi2018yo' }, + { name: 'JOI 2025 qual 1A', contestId: 'joi2025yo1a' }, + { name: 'JOI 2025 qual 1B', contestId: 'joi2025yo1b' }, + { name: 'JOI 2025 qual 1C', contestId: 'joi2025yo1c' }, + { name: 'JOI 2020 qual 2', contestId: 'joi2020yo2' }, + { name: 'JOI 2023 qual 2', contestId: 'joi2023yo2' }, + { name: 'JOI 2024 qual 2', contestId: 'joi2024yo2' }, + //JOI Finals (2006-) + { name: 'JOI 2006 final', contestId: 'joi2006ho' }, + { name: 'JOI 2007 final', contestId: 'joi2007ho' }, + { name: 'JOI 2019 final', contestId: 'joi2019ho' }, + { name: 'JOI 2020 final', contestId: 'joi2020ho' }, + { name: 'JOI 2022 final', contestId: 'joi2022ho' }, + { name: 'JOI 2023 final', contestId: 'joi2023ho' }, + { name: 'JOI 2024 final', contestId: 'joi2024ho' }, + // JOI Spring Camp (2007-) + { name: 'JOI 2007 Spring', contestId: 'joisc2007' }, + { name: 'JOI 2008 Spring', contestId: 'joisc2008' }, + { name: 'JOI 2020 Spring', contestId: 'joisc2020' }, + { name: 'JOI 2022 Spring', contestId: 'joisc2022' }, + { name: 'JOI 2023 Spring', contestId: 'joisp2023' }, + { name: 'JOI 2024 Spring', contestId: 'joisp2024' }, + // JOI Open Contests (2022-) + { name: 'JOI Open 2022', contestId: 'joiopen2022' }, + { name: 'JOI Open 2023', contestId: 'joiopen2023' }, + { name: 'JOI Open 2024', contestId: 'joiopen2024' }, + // JOIG Contests (2021-) + { name: 'JOIG 2021 open', contestId: 'joig2021-open' }, + { name: 'JOIG 2022 open', contestId: 'joig2022-open' }, + { name: 'JOIG 2023 open', contestId: 'joig2023-open' }, + { name: 'JOIG 2024 open', contestId: 'joig2024-open' }, + // JOIG Spring Camp (2022-) + // Note: Contest ID pattern changed from joisc to joisp starting from 2023 + { name: 'JOIG 2022 Spring', contestId: 'joigsc2022' }, + { name: 'JOIG 2023 Spring', contestId: 'joigsp2023' }, + { name: 'JOIG 2024 Spring', contestId: 'joigsp2024' }, ]; export const joi = joiContestData.map(({ name, contestId }) =>