diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 4a9499b21..803e06844 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -4820,6 +4820,56 @@ export const tasks = [ title: 'A01. The First Problem', grade: 'Q10', }, + { + id: 'joi2025_yo1c_d', + contest_id: 'joi2025yo1c', + problem_index: 'D', + name: '周期文字列 (Cycle String)', + title: 'D. 周期文字列 (Cycle String)', + }, + { + id: 'joi2025_yo1c_c', + contest_id: 'joi2025yo1c', + problem_index: 'C', + name: 'いずれか片方 (Either, but Not Both)', + title: 'C. いずれか片方 (Either, but Not Both)', + }, + { + id: 'joi2025_yo1c_b', + contest_id: 'joi2025yo1c', + problem_index: 'B', + name: 'ブラックジャック (Blackjack)', + title: 'B. ブラックジャック (Blackjack)', + }, + { + id: 'joi2025_yo1c_a', + contest_id: 'joi2025yo1c', + problem_index: 'A', + name: '所持金 (Money On Me)', + title: 'A. 所持金 (Money On Me)', + }, + { + id: 'joi2025_yo1b_d', + contest_id: 'joi2025yo1b', + problem_index: 'D', + name: '三角足し算 (Triangle Addition)', + title: 'D. 三角足し算 (Triangle Addition)', + }, + { + id: 'joi2025_yo1b_c', + contest_id: 'joi2025yo1b', + problem_index: 'C', + name: 'じゃんけん (Rock-Scissors-Paper)', + title: 'C. じゃんけん (Rock-Scissors-Paper)', + }, + { + id: 'joi2025_yo1b_b', + contest_id: 'joi2025yo1b', + problem_index: 'B', + name: '鉄道旅行 3 (Railway Trip 3)', + title: 'B. 鉄道旅行 3 (Railway Trip 3)', + grade: 'Q8', + }, { id: 'joi2025_yo1b_a', contest_id: 'joi2025yo1b', @@ -4828,6 +4878,22 @@ export const tasks = [ title: 'A. 徒競走 (Footrace)', grade: 'Q9', }, + { + id: 'joi2025_yo1a_d', + contest_id: 'joi2025yo1a', + problem_index: 'D', + name: 'どら焼き (Dorayaki)', + title: 'D. どら焼き (Dorayaki)', + grade: 'Q7', + }, + { + id: 'joi2025_yo1a_c', + contest_id: 'joi2025yo1a', + problem_index: 'C', + name: 'OIJ (OIJ)', + title: 'C. OIJ (OIJ)', + grade: 'Q7', + }, { id: 'joi2025_yo1a_b', contest_id: 'joi2025yo1a', @@ -4916,6 +4982,38 @@ export const tasks = [ title: 'A. 立方体 (Cube)', grade: 'Q10', }, + { + id: 'joi2022_yo1a_d', + contest_id: 'joi2022yo1a', + problem_index: 'D', + name: '箱と鍵 (Boxes and Keys)', + title: 'D. 箱と鍵 (Boxes and Keys)', + grade: 'Q6', + }, + { + id: 'joi2022_yo1a_c', + contest_id: 'joi2022yo1a', + problem_index: 'C', + name: '複雑な文字列 (Complex String)', + title: 'C. 複雑な文字列 (Complex String)', + grade: 'Q7', + }, + { + id: 'joi2022_yo1a_b', + contest_id: 'joi2022yo1a', + problem_index: 'B', + name: '移動 (Moving)', + title: 'B. 移動 (Moving)', + grade: 'Q8', + }, + { + id: 'joi2022_yo1a_a', + contest_id: 'joi2022yo1a', + problem_index: 'A', + name: '余り (Remainder)', + title: 'A. 余り (Remainder)', + grade: 'Q9', + }, { id: 'joi2021_yo1b_a', contest_id: 'joi2021yo1b', @@ -4932,6 +5030,102 @@ export const tasks = [ title: 'A. 金平糖 (Konpeito)', grade: 'Q8', }, + { + id: 'joi2021_yo1a_c', + contest_id: 'joi2021yo1a', + problem_index: 'C', + name: '共通要素 (Common Elements)', + title: 'C. 共通要素 (Common Elements)', + grade: 'Q6', + }, + { + id: 'joi2021_yo1a_b', + contest_id: 'joi2021yo1a', + problem_index: 'B', + name: 'JOI ソート (JOI Sort)', + title: 'B. JOI ソート (JOI Sort)', + grade: 'Q7', + }, + { + id: 'joi2021_yo1a_a', + contest_id: 'joi2021yo1a', + problem_index: 'A', + name: '2 番目に大きい整数 (The Second Largest Integer)', + title: 'A. 2 番目に大きい整数 (The Second Largest Integer)', + grade: 'Q8', + }, + { + id: 'joi2020_yo1c_c', + contest_id: 'joi2020yo1c', + problem_index: 'C', + name: '最長昇順連続部分列 (Longest Ascending Contiguous Subsequence)', + title: 'C. 最長昇順連続部分列 (Longest Ascending Contiguous Subsequence)', + grade: 'Q6', + }, + { + id: 'joi2020_yo1c_b', + contest_id: 'joi2020yo1c', + problem_index: 'B', + name: 'キャピタリゼーション (Capitalization)', + title: 'B. キャピタリゼーション (Capitalization)', + grade: 'Q7', + }, + { + id: 'joi2020_yo1c_a', + contest_id: 'joi2020yo1c', + problem_index: 'A', + name: 'X に最も近い値 (The Nearest Value)', + title: 'A. X に最も近い値 (The Nearest Value)', + grade: 'Q7', + }, + { + id: 'joi2020_yo1b_c', + contest_id: 'joi2020yo1b', + problem_index: 'C', + name: '最頻値 (Mode)', + title: 'C. 最頻値 (Mode)', + grade: 'Q6', + }, + { + id: 'joi2020_yo1b_b', + contest_id: 'joi2020yo1b', + problem_index: 'B', + name: '文字列の反転 (Inversion of a String)', + title: 'B. 文字列の反転 (Inversion of a String)', + grade: 'Q7', + }, + { + id: 'joi2020_yo1b_a', + contest_id: 'joi2020yo1b', + problem_index: 'A', + name: '試験 (Exam)', + title: 'A. 試験 (Exam)', + grade: 'Q8', + }, + { + id: 'joi2020_yo1a_c', + contest_id: 'joi2020yo1a', + problem_index: 'C', + name: 'マージ (Merge)', + title: 'C. マージ (Merge)', + grade: 'Q5', + }, + { + id: 'joi2020_yo1a_b', + contest_id: 'joi2020yo1a', + problem_index: 'B', + name: '母音を数える (Counting Vowels)', + title: 'B. 母音を数える (Counting Vowels)', + grade: 'Q7', + }, + { + id: 'joi2020_yo1a_a', + contest_id: 'joi2020yo1a', + problem_index: 'A', + name: '3 つの整数 (Three Integers)', + title: 'A. 3 つの整数 (Three Integers)', + grade: 'Q9', + }, { id: 'joi2016yo_a', contest_id: 'joi2016yo', diff --git a/src/lib/components/TaskTables/TaskTable.svelte b/src/lib/components/TaskTables/TaskTable.svelte index 8428f9b4c..fe353999e 100644 --- a/src/lib/components/TaskTables/TaskTable.svelte +++ b/src/lib/components/TaskTables/TaskTable.svelte @@ -106,7 +106,10 @@ } function getBodyCellClasses(taskResult: TaskResult, totalColumns: number): string { - const baseClasses = 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1'; + const baseClasses = + totalColumns >= 5 + ? 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1' + : 'w-1/2 xs:w-1/3 sm:w-1/4 px-1 py-1'; const additionalClasses = totalColumns > 8 ? '2xl:w-1/7 py-2' : ''; const backgroundColor = getBackgroundColor(taskResult); @@ -199,7 +202,12 @@
- Round + + Round + {#if contestTable.headerIds} {#each contestTable.headerIds as taskTableHeaderId (taskTableHeaderId)} @@ -224,7 +232,8 @@ {#if contestTable.displayConfig.isShownRoundLabel} {getContestRoundLabel(provider, contestId)} diff --git a/src/lib/types/contest_table_provider.ts b/src/lib/types/contest_table_provider.ts index ff3c8c42e..449ba8155 100644 --- a/src/lib/types/contest_table_provider.ts +++ b/src/lib/types/contest_table_provider.ts @@ -125,10 +125,12 @@ export type ContestTablesMetaData = { * @interface ContestTableDisplayConfig * @property {boolean} isShownHeader - Whether to display the table header * @property {boolean} isShownRoundLabel - Whether to display round labels in the contest table + * @property {string} roundLabelWidth - tailwind CSS width for the round label column, e.g., "w-16" or "w-20" * @property {boolean} isShownTaskIndex - Whether to display task index in the contest table cells */ export interface ContestTableDisplayConfig { isShownHeader: boolean; isShownRoundLabel: boolean; + roundLabelWidth: string; isShownTaskIndex: boolean; } diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 576d9c786..825179240 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -98,6 +98,7 @@ export abstract class ContestTableProviderBase implements ContestTableProvider { return { isShownHeader: true, isShownRoundLabel: true, + roundLabelWidth: 'xl:w-16', // Default width for task index column isShownTaskIndex: false, }; } @@ -230,6 +231,7 @@ export class EDPCProvider extends ContestTableProviderBase { return { isShownHeader: false, isShownRoundLabel: false, + roundLabelWidth: '', // No specific width for task index in EDPC isShownTaskIndex: true, }; } @@ -261,6 +263,7 @@ export class TDPCProvider extends ContestTableProviderBase { return { isShownHeader: false, isShownRoundLabel: false, + roundLabelWidth: '', // No specific width for task index in TDPC isShownTaskIndex: true, }; } @@ -270,6 +273,41 @@ export class TDPCProvider extends ContestTableProviderBase { } } +const regexForJoiFirstQualRound = /^(joi)(\d{4})(yo1)(a|b|c)$/i; + +export class JOIFirstQualRoundProvider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return regexForJoiFirstQualRound.test(taskResult.contest_id); + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'JOI 一次予選', + abbreviationName: 'joiFirstQualRound', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + isShownTaskIndex: false, + roundLabelWidth: 'xl:w-28', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('JOI 一次予選 ', ''); + } +} + /** * A class that manages individual provider groups * Manages multiple ContestTableProviders as a single group, @@ -415,6 +453,12 @@ export const prepareContestProviderPresets = () => { { contestType: ContestType.EDPC, provider: new EDPCProvider(ContestType.EDPC) }, { contestType: ContestType.TDPC, provider: new TDPCProvider(ContestType.TDPC) }, ), + + JOIFirstQualRound: () => + new ContestTableProviderGroup(`JOI 一次予選`, { + buttonLabel: 'JOI 一次予選', + ariaLabel: 'Filter JOI First Qualifying Round', + }).addProvider(ContestType.JOI, new JOIFirstQualRoundProvider(ContestType.JOI)), }; }; @@ -423,6 +467,7 @@ export const contestTableProviderGroups = { abc319Onwards: prepareContestProviderPresets().ABC319Onwards(), fromAbc212ToAbc318: prepareContestProviderPresets().ABC212ToABC318(), dps: prepareContestProviderPresets().dps(), // Dynamic Programming (DP) Contests + joiFirstQualRound: prepareContestProviderPresets().JOIFirstQualRound(), }; export type ContestTableProviderGroups = keyof typeof contestTableProviderGroups; diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index 3d7f596cb..530778ee7 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -9,6 +9,7 @@ import { ABC212ToABC318Provider, EDPCProvider, TDPCProvider, + JOIFirstQualRoundProvider, ContestTableProviderGroup, prepareContestProviderPresets, } from '$lib/utils/contest_table_provider'; @@ -23,6 +24,8 @@ vi.mock('$lib/utils/contest', () => ({ return ContestType.EDPC; } else if (contestId === 'tdpc') { return ContestType.TDPC; + } else if (contestId.startsWith('joi')) { + return ContestType.JOI; } return ContestType.OTHERS; @@ -33,6 +36,16 @@ vi.mock('$lib/utils/contest', () => ({ return `ABC ${contestId.replace('abc', '')}`; } else if (contestId === 'dp' || contestId === 'tdpc') { return ''; + } else if (contestId.startsWith('joi')) { + // First qual round + const matched = contestId.match(/joi(\d{4})yo1([abc])/); + + if (matched) { + const [, year, round] = matched; + const roundMap: Record = { a: '1', b: '2', c: '3' }; + + return `${year} 第 ${roundMap[round]} 回`; + } } return contestId; @@ -126,6 +139,7 @@ describe('ContestTableProviderBase and implementations', () => { expect(displayConfig.isShownHeader).toBe(true); expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.roundLabelWidth).toBe('xl:w-16'); expect(displayConfig.isShownTaskIndex).toBe(false); }); }); @@ -165,6 +179,7 @@ describe('ContestTableProviderBase and implementations', () => { expect(displayConfig.isShownHeader).toBe(true); expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.roundLabelWidth).toBe('xl:w-16'); expect(displayConfig.isShownTaskIndex).toBe(false); }); }); @@ -204,6 +219,7 @@ describe('ContestTableProviderBase and implementations', () => { expect(displayConfig.isShownHeader).toBe(true); expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.roundLabelWidth).toBe('xl:w-16'); expect(displayConfig.isShownTaskIndex).toBe(false); }); }); @@ -223,6 +239,7 @@ describe('ContestTableProviderBase and implementations', () => { expect(displayConfig.isShownHeader).toBe(false); expect(displayConfig.isShownRoundLabel).toBe(false); + expect(displayConfig.roundLabelWidth).toBe(''); expect(displayConfig.isShownTaskIndex).toBe(true); }); @@ -249,6 +266,7 @@ describe('ContestTableProviderBase and implementations', () => { expect(displayConfig.isShownHeader).toBe(false); expect(displayConfig.isShownRoundLabel).toBe(false); + expect(displayConfig.roundLabelWidth).toBe(''); expect(displayConfig.isShownTaskIndex).toBe(true); }); @@ -260,6 +278,123 @@ describe('ContestTableProviderBase and implementations', () => { }); }); + describe('JOI First Qual Round provider', () => { + test('expects to filter tasks to include only JOI contests', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a' }, + { contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_a' }, + { contest_id: 'joi2023yo1a', task_id: 'joi2023yo1a_a' }, + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + { contest_id: 'abc123', task_id: 'abc123_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true); + expect(filtered?.length).toBe(4); + expect(filtered?.every((task) => task.contest_id.match(/joi\d{4}yo1[abc]/))).toBe(true); + }); + + test('expects to filter contests by year correctly', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a' }, + { contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_a' }, + { contest_id: 'joi2023yo1a', task_id: 'joi2023yo1a_a' }, + { contest_id: 'joi2023yo1b', task_id: 'joi2023yo1b_b' }, + { contest_id: 'joi2022yo1a', task_id: 'joi2022yo1a_c' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.length).toBe(6); + expect(filtered?.filter((task) => task.contest_id.includes('2024')).length).toBe(3); + expect(filtered?.filter((task) => task.contest_id.includes('2023')).length).toBe(2); + expect(filtered?.filter((task) => task.contest_id.includes('2022')).length).toBe(1); + }); + + test('expects to get correct metadata', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('JOI 一次予選'); + expect(metadata.abbreviationName).toBe('joiFirstQualRound'); + }); + + test('expects to get correct display configuration', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(true); + expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.roundLabelWidth).toBe('xl:w-28'); + expect(displayConfig.isShownTaskIndex).toBe(false); + }); + + test('expects to format contest round label correctly', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + + expect(provider.getContestRoundLabel('joi2024yo1a')).toBe('2024 第 1 回'); + expect(provider.getContestRoundLabel('joi2024yo1b')).toBe('2024 第 2 回'); + expect(provider.getContestRoundLabel('joi2024yo1c')).toBe('2024 第 3 回'); + expect(provider.getContestRoundLabel('joi2023yo1a')).toBe('2023 第 1 回'); + expect(provider.getContestRoundLabel('joi2023yo1b')).toBe('2023 第 2 回'); + expect(provider.getContestRoundLabel('joi2023yo1c')).toBe('2023 第 3 回'); + }); + + test('expects to handle invalid contest IDs gracefully', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + + expect(provider.getContestRoundLabel('invalid-id')).toBe('invalid-id'); + expect(provider.getContestRoundLabel('joi2024yo1d')).toBe('joi2024yo1d'); // Invalid round + expect(provider.getContestRoundLabel('joi2024yo2')).toBe('joi2024yo2'); // Not first qual round + }); + + test('expects to generate correct table structure', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a', task_table_index: 'A' }, + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_b', task_table_index: 'B' }, + { contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a', task_table_index: 'A' }, + { contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_c', task_table_index: 'C' }, + ]; + + const table = provider.generateTable(mockJOITasks as any); + + expect(table).toHaveProperty('joi2024yo1a'); + expect(table).toHaveProperty('joi2024yo1b'); + expect(table).toHaveProperty('joi2024yo1c'); + expect(table.joi2024yo1a).toHaveProperty('A'); + expect(table.joi2024yo1a).toHaveProperty('B'); + expect(table.joi2024yo1b).toHaveProperty('A'); + expect(table.joi2024yo1c).toHaveProperty('C'); + }); + + test('expects to get contest round IDs correctly', () => { + const provider = new JOIFirstQualRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a' }, + { contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_a' }, + { contest_id: 'joi2023yo1a', task_id: 'joi2023yo1a_a' }, + { contest_id: 'joi2023yo1b', task_id: 'joi2023yo1b_a' }, + { contest_id: 'joi2023yo1c', task_id: 'joi2023yo1c_a' }, + ]; + + const roundIds = provider.getContestRoundIds(mockJOITasks as any); + + expect(roundIds).toContain('joi2024yo1a'); + expect(roundIds).toContain('joi2024yo1b'); + expect(roundIds).toContain('joi2024yo1c'); + expect(roundIds).toContain('joi2023yo1a'); + expect(roundIds).toContain('joi2023yo1b'); + expect(roundIds).toContain('joi2023yo1c'); + }); + }); + describe('Common provider functionality', () => { test('expects to get contest round IDs correctly', () => { const provider = new ABCLatest20RoundsProvider(ContestType.ABC);