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