Skip to content

Commit f5e1bd6

Browse files
committed
✨ Add JOI first qual round to contest table (#2321)
1 parent 2c4f2a9 commit f5e1bd6

File tree

4 files changed

+194
-3
lines changed

4 files changed

+194
-3
lines changed

src/lib/components/TaskTables/TaskTable.svelte

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@
106106
}
107107
108108
function getBodyCellClasses(taskResult: TaskResult, totalColumns: number): string {
109-
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';
109+
const baseClasses =
110+
totalColumns >= 5
111+
? 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1'
112+
: 'w-1/2 xs:w-1/3 sm:w-1/4 px-1 py-1';
110113
const additionalClasses = totalColumns > 8 ? '2xl:w-1/7 py-2' : '';
111114
const backgroundColor = getBackgroundColor(taskResult);
112115
@@ -199,7 +202,12 @@
199202
<div class="w-full sticky top-0 z-20 border-b border-gray-200 dark:border-gray-100">
200203
<Table id="task-table" class="text-md table-fixed w-full" aria-label="Task table">
201204
<TableHead class="text-sm border-gray-200 dark:border-gray-100">
202-
<TableHeadCell class="w-full xl:w-16 px-2 text-center" scope="col">Round</TableHeadCell>
205+
<TableHeadCell
206+
class="w-full {contestTable.displayConfig.roundLabelWidth} px-2 text-center"
207+
scope="col"
208+
>
209+
Round
210+
</TableHeadCell>
203211

204212
{#if contestTable.headerIds}
205213
{#each contestTable.headerIds as taskTableHeaderId (taskTableHeaderId)}
@@ -224,7 +232,8 @@
224232
<TableBodyRow class={getBodyRowClasses(totalColumns)}>
225233
{#if contestTable.displayConfig.isShownRoundLabel}
226234
<TableBodyCell
227-
class="w-full xl:w-16 truncate px-2 py-2 text-center bg-gray-50 dark:bg-gray-800"
235+
class="w-full {contestTable.displayConfig
236+
.roundLabelWidth} truncate px-2 py-2 text-center bg-gray-50 dark:bg-gray-800"
228237
>
229238
{getContestRoundLabel(provider, contestId)}
230239
</TableBodyCell>

src/lib/types/contest_table_provider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,12 @@ export type ContestTablesMetaData = {
125125
* @interface ContestTableDisplayConfig
126126
* @property {boolean} isShownHeader - Whether to display the table header
127127
* @property {boolean} isShownRoundLabel - Whether to display round labels in the contest table
128+
* @property {string} roundLabelWidth - tailwind CSS width for the round label column, e.g., "w-16" or "w-20"
128129
* @property {boolean} isShownTaskIndex - Whether to display task index in the contest table cells
129130
*/
130131
export interface ContestTableDisplayConfig {
131132
isShownHeader: boolean;
132133
isShownRoundLabel: boolean;
134+
roundLabelWidth: string;
133135
isShownTaskIndex: boolean;
134136
}

src/lib/utils/contest_table_provider.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export abstract class ContestTableProviderBase implements ContestTableProvider {
9898
return {
9999
isShownHeader: true,
100100
isShownRoundLabel: true,
101+
roundLabelWidth: 'xl:w-16', // Default width for task index column
101102
isShownTaskIndex: false,
102103
};
103104
}
@@ -230,6 +231,7 @@ export class EDPCProvider extends ContestTableProviderBase {
230231
return {
231232
isShownHeader: false,
232233
isShownRoundLabel: false,
234+
roundLabelWidth: '', // No specific width for task index in EDPC
233235
isShownTaskIndex: true,
234236
};
235237
}
@@ -261,6 +263,7 @@ export class TDPCProvider extends ContestTableProviderBase {
261263
return {
262264
isShownHeader: false,
263265
isShownRoundLabel: false,
266+
roundLabelWidth: '', // No specific width for task index in TDPC
264267
isShownTaskIndex: true,
265268
};
266269
}
@@ -270,6 +273,41 @@ export class TDPCProvider extends ContestTableProviderBase {
270273
}
271274
}
272275

276+
const regexForJoiFirstQualRound = /^(joi)(\d{4})(yo1)(a|b|c)/i;
277+
278+
export class JOIFirstQualRoundProvider extends ContestTableProviderBase {
279+
protected setFilterCondition(): (taskResult: TaskResult) => boolean {
280+
return (taskResult: TaskResult) => {
281+
if (classifyContest(taskResult.contest_id) !== this.contestType) {
282+
return false;
283+
}
284+
285+
return regexForJoiFirstQualRound.test(taskResult.contest_id);
286+
};
287+
}
288+
289+
getMetadata(): ContestTableMetaData {
290+
return {
291+
title: 'JOI 一次予選',
292+
abbreviationName: 'joiFirstQualRound',
293+
};
294+
}
295+
296+
getDisplayConfig(): ContestTableDisplayConfig {
297+
return {
298+
isShownHeader: true,
299+
isShownRoundLabel: true,
300+
isShownTaskIndex: false,
301+
roundLabelWidth: 'xl:w-28',
302+
};
303+
}
304+
305+
getContestRoundLabel(contestId: string): string {
306+
const contestNameLabel = getContestNameLabel(contestId);
307+
return contestNameLabel.replace('JOI 一次予選 ', '');
308+
}
309+
}
310+
273311
/**
274312
* A class that manages individual provider groups
275313
* Manages multiple ContestTableProviders as a single group,
@@ -415,6 +453,12 @@ export const prepareContestProviderPresets = () => {
415453
{ contestType: ContestType.EDPC, provider: new EDPCProvider(ContestType.EDPC) },
416454
{ contestType: ContestType.TDPC, provider: new TDPCProvider(ContestType.TDPC) },
417455
),
456+
457+
JOIFirstQualRound: () =>
458+
new ContestTableProviderGroup(`JOI 一次予選`, {
459+
buttonLabel: 'JOI 一次予選',
460+
ariaLabel: 'Filter JOI First Qualifying Round',
461+
}).addProvider(ContestType.JOI, new JOIFirstQualRoundProvider(ContestType.JOI)),
418462
};
419463
};
420464

@@ -423,6 +467,7 @@ export const contestTableProviderGroups = {
423467
abc319Onwards: prepareContestProviderPresets().ABC319Onwards(),
424468
fromAbc212ToAbc318: prepareContestProviderPresets().ABC212ToABC318(),
425469
dps: prepareContestProviderPresets().dps(), // Dynamic Programming (DP) Contests
470+
joiFirstQualRound: prepareContestProviderPresets().JOIFirstQualRound(),
426471
};
427472

428473
export type ContestTableProviderGroups = keyof typeof contestTableProviderGroups;

src/test/lib/utils/contest_table_provider.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ABC212ToABC318Provider,
1010
EDPCProvider,
1111
TDPCProvider,
12+
JOIFirstQualRoundProvider,
1213
ContestTableProviderGroup,
1314
prepareContestProviderPresets,
1415
} from '$lib/utils/contest_table_provider';
@@ -23,6 +24,8 @@ vi.mock('$lib/utils/contest', () => ({
2324
return ContestType.EDPC;
2425
} else if (contestId === 'tdpc') {
2526
return ContestType.TDPC;
27+
} else if (contestId.startsWith('joi')) {
28+
return ContestType.JOI;
2629
}
2730

2831
return ContestType.OTHERS;
@@ -33,6 +36,16 @@ vi.mock('$lib/utils/contest', () => ({
3336
return `ABC ${contestId.replace('abc', '')}`;
3437
} else if (contestId === 'dp' || contestId === 'tdpc') {
3538
return '';
39+
} else if (contestId.startsWith('joi')) {
40+
// First qual round
41+
const matched = contestId.match(/joi(\d{4})yo1([abc])/);
42+
43+
if (matched) {
44+
const [, year, round] = matched;
45+
const roundMap: Record<string, string> = { a: '1', b: '2', c: '3' };
46+
47+
return `${year}${roundMap[round]} 回`;
48+
}
3649
}
3750

3851
return contestId;
@@ -126,6 +139,7 @@ describe('ContestTableProviderBase and implementations', () => {
126139

127140
expect(displayConfig.isShownHeader).toBe(true);
128141
expect(displayConfig.isShownRoundLabel).toBe(true);
142+
expect(displayConfig.roundLabelWidth).toBe('xl:w-16');
129143
expect(displayConfig.isShownTaskIndex).toBe(false);
130144
});
131145
});
@@ -165,6 +179,7 @@ describe('ContestTableProviderBase and implementations', () => {
165179

166180
expect(displayConfig.isShownHeader).toBe(true);
167181
expect(displayConfig.isShownRoundLabel).toBe(true);
182+
expect(displayConfig.roundLabelWidth).toBe('xl:w-16');
168183
expect(displayConfig.isShownTaskIndex).toBe(false);
169184
});
170185
});
@@ -204,6 +219,7 @@ describe('ContestTableProviderBase and implementations', () => {
204219

205220
expect(displayConfig.isShownHeader).toBe(true);
206221
expect(displayConfig.isShownRoundLabel).toBe(true);
222+
expect(displayConfig.roundLabelWidth).toBe('xl:w-16');
207223
expect(displayConfig.isShownTaskIndex).toBe(false);
208224
});
209225
});
@@ -223,6 +239,7 @@ describe('ContestTableProviderBase and implementations', () => {
223239

224240
expect(displayConfig.isShownHeader).toBe(false);
225241
expect(displayConfig.isShownRoundLabel).toBe(false);
242+
expect(displayConfig.roundLabelWidth).toBe('');
226243
expect(displayConfig.isShownTaskIndex).toBe(true);
227244
});
228245

@@ -249,6 +266,7 @@ describe('ContestTableProviderBase and implementations', () => {
249266

250267
expect(displayConfig.isShownHeader).toBe(false);
251268
expect(displayConfig.isShownRoundLabel).toBe(false);
269+
expect(displayConfig.roundLabelWidth).toBe('');
252270
expect(displayConfig.isShownTaskIndex).toBe(true);
253271
});
254272

@@ -260,6 +278,123 @@ describe('ContestTableProviderBase and implementations', () => {
260278
});
261279
});
262280

281+
describe('JOI First Qual Round provider', () => {
282+
test('expects to filter tasks to include only JOI contests', () => {
283+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
284+
const mockJOITasks = [
285+
{ contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' },
286+
{ contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a' },
287+
{ contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_a' },
288+
{ contest_id: 'joi2023yo1a', task_id: 'joi2023yo1a_a' },
289+
{ contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' },
290+
{ contest_id: 'abc123', task_id: 'abc123_a' },
291+
];
292+
293+
const filtered = provider.filter(mockJOITasks as any);
294+
295+
expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true);
296+
expect(filtered?.length).toBe(4);
297+
expect(filtered?.every((task) => task.contest_id.match(/joi\d{4}yo1[abc]/))).toBe(true);
298+
});
299+
300+
test('expects to filter contests by year correctly', () => {
301+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
302+
const mockJOITasks = [
303+
{ contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' },
304+
{ contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a' },
305+
{ contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_a' },
306+
{ contest_id: 'joi2023yo1a', task_id: 'joi2023yo1a_a' },
307+
{ contest_id: 'joi2023yo1b', task_id: 'joi2023yo1b_b' },
308+
{ contest_id: 'joi2022yo1a', task_id: 'joi2022yo1a_c' },
309+
];
310+
311+
const filtered = provider.filter(mockJOITasks as any);
312+
313+
expect(filtered?.length).toBe(6);
314+
expect(filtered?.filter((task) => task.contest_id.includes('2024')).length).toBe(3);
315+
expect(filtered?.filter((task) => task.contest_id.includes('2023')).length).toBe(2);
316+
expect(filtered?.filter((task) => task.contest_id.includes('2022')).length).toBe(1);
317+
});
318+
319+
test('expects to get correct metadata', () => {
320+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
321+
const metadata = provider.getMetadata();
322+
323+
expect(metadata.title).toBe('JOI 一次予選');
324+
expect(metadata.abbreviationName).toBe('joiFirstQualRound');
325+
});
326+
327+
test('expects to get correct display configuration', () => {
328+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
329+
const displayConfig = provider.getDisplayConfig();
330+
331+
expect(displayConfig.isShownHeader).toBe(true);
332+
expect(displayConfig.isShownRoundLabel).toBe(true);
333+
expect(displayConfig.roundLabelWidth).toBe('xl:w-28');
334+
expect(displayConfig.isShownTaskIndex).toBe(false);
335+
});
336+
337+
test('expects to format contest round label correctly', () => {
338+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
339+
340+
expect(provider.getContestRoundLabel('joi2024yo1a')).toBe('2024 第 1 回');
341+
expect(provider.getContestRoundLabel('joi2024yo1b')).toBe('2024 第 2 回');
342+
expect(provider.getContestRoundLabel('joi2024yo1c')).toBe('2024 第 3 回');
343+
expect(provider.getContestRoundLabel('joi2023yo1a')).toBe('2023 第 1 回');
344+
expect(provider.getContestRoundLabel('joi2023yo1b')).toBe('2023 第 2 回');
345+
expect(provider.getContestRoundLabel('joi2023yo1c')).toBe('2023 第 3 回');
346+
});
347+
348+
test('expects to handle invalid contest IDs gracefully', () => {
349+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
350+
351+
expect(provider.getContestRoundLabel('invalid-id')).toBe('invalid-id');
352+
expect(provider.getContestRoundLabel('joi2024yo1d')).toBe('joi2024yo1d'); // Invalid round
353+
expect(provider.getContestRoundLabel('joi2024yo2')).toBe('joi2024yo2'); // Not first qual round
354+
});
355+
356+
test('expects to generate correct table structure', () => {
357+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
358+
const mockJOITasks = [
359+
{ contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a', task_table_index: 'A' },
360+
{ contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_b', task_table_index: 'B' },
361+
{ contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a', task_table_index: 'A' },
362+
{ contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_c', task_table_index: 'C' },
363+
];
364+
365+
const table = provider.generateTable(mockJOITasks as any);
366+
367+
expect(table).toHaveProperty('joi2024yo1a');
368+
expect(table).toHaveProperty('joi2024yo1b');
369+
expect(table).toHaveProperty('joi2024yo1c');
370+
expect(table.joi2024yo1a).toHaveProperty('A');
371+
expect(table.joi2024yo1a).toHaveProperty('B');
372+
expect(table.joi2024yo1b).toHaveProperty('A');
373+
expect(table.joi2024yo1c).toHaveProperty('C');
374+
});
375+
376+
test('expects to get contest round IDs correctly', () => {
377+
const provider = new JOIFirstQualRoundProvider(ContestType.JOI);
378+
const mockJOITasks = [
379+
{ contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' },
380+
{ contest_id: 'joi2024yo1b', task_id: 'joi2024yo1b_a' },
381+
{ contest_id: 'joi2024yo1c', task_id: 'joi2024yo1c_a' },
382+
{ contest_id: 'joi2023yo1a', task_id: 'joi2023yo1a_a' },
383+
{ contest_id: 'joi2023yo1b', task_id: 'joi2023yo1b_a' },
384+
{ contest_id: 'joi2023yo1c', task_id: 'joi2023yo1c_a' },
385+
];
386+
387+
const roundIds = provider.getContestRoundIds(mockJOITasks as any);
388+
389+
expect(roundIds).toContain('joi2024yo1a');
390+
expect(roundIds).toContain('joi2024yo1b');
391+
expect(roundIds).toContain('joi2024yo1c');
392+
expect(roundIds).toContain('joi2023yo1a');
393+
expect(roundIds).toContain('joi2023yo1b');
394+
expect(roundIds).toContain('joi2023yo1c');
395+
});
396+
});
397+
263398
describe('Common provider functionality', () => {
264399
test('expects to get contest round IDs correctly', () => {
265400
const provider = new ABCLatest20RoundsProvider(ContestType.ABC);

0 commit comments

Comments
 (0)