From ce588fe7ead48e34bf36292059717ba01b362a92 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 10 Jun 2026 11:37:16 +0000 Subject: [PATCH 1/7] feat(contest): add ICPC Prelim contest table provider (Phase 0-3) - Add 28 years (1998-2025) of ICPCPrelim seed data to prisma/tasks.ts - Implement AojIcpcPrelimProvider with per-year instantiation pattern - Add buildAojIcpcLetterMap util with override map for judge-gap years - Add titleFontSize optional field to ContestTableMetaData for per-provider font control - Register AojIcpcPrelimProvider in contest_table_provider_groups - Add plan.md under docs/dev-notes/2026-06-10/aoj-icpc-prelim-table/ Co-Authored-By: Claude Sonnet 4.6 --- .../2026-06-10/aoj-icpc-prelim-table/plan.md | 248 ++++ prisma/tasks.ts | 1309 +++++++++++++++++ .../components/contest-table/TaskTable.svelte | 5 +- .../contest-table/contest_table_provider.ts | 2 + .../contest-table/aoj_icpc_labels.test.ts | 56 + .../utils/contest-table/aoj_icpc_labels.ts | 25 + .../contest-table/aoj_icpc_providers.test.ts | 391 +++++ .../utils/contest-table/aoj_icpc_providers.ts | 72 + .../contest-table/contest_table_provider.ts | 2 + .../contest_table_provider_groups.test.ts | 16 + .../contest_table_provider_groups.ts | 18 + 11 files changed, 2143 insertions(+), 1 deletion(-) create mode 100644 docs/dev-notes/2026-06-10/aoj-icpc-prelim-table/plan.md create mode 100644 src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts create mode 100644 src/features/tasks/utils/contest-table/aoj_icpc_labels.ts create mode 100644 src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts create mode 100644 src/features/tasks/utils/contest-table/aoj_icpc_providers.ts diff --git a/docs/dev-notes/2026-06-10/aoj-icpc-prelim-table/plan.md b/docs/dev-notes/2026-06-10/aoj-icpc-prelim-table/plan.md new file mode 100644 index 000000000..70ca259ff --- /dev/null +++ b/docs/dev-notes/2026-06-10/aoj-icpc-prelim-table/plan.md @@ -0,0 +1,248 @@ +# ICPC 国内予選テーブル 実装計画 (Issue #3633) + +## 概要 + +Issue #3633「ICPC・国内予選」のテーブルを追加する。`src/lib/utils/contest.ts` の `AOJ_ICPC`(`ICPC_TRANSLATIONS = { Prelim: ' 国内予選 ', ... }`)相当の分類・ラベルは**既に存在**しており、`classifyContest` / `contestTypePriorities`(priority 23)/ `getContestNameLabel` / AOJ URL 生成(`AojGenerator`)まで実装済み。一方で **テーブルプロバイダ(ContestTableProviderGroup)は未実装**で、AOJ 系(PCK/JAG/ICPC/COURSES/UNIVERSITY)の表示テーブルは1つも登録されていない。本タスクは ICPC 国内予選を「最初の AOJ テーブル」として、EDPC(PR #2286)相当の形式で追加する。 + +スコープは **国内予選のみ**(`ICPCPrelim{year}`、1998〜2025 の28年分)。 + +### 今回の3つの新規要件(既存実装に前例なし) + +1. **年度単位でテーブル分割(新しい年度が上)**: 年度クラスを28個作る代わりに、**1つのベースクラスをコンストラクタで年度指定して28回インスタンス化**する。既存の JOI/Tessoku は「セクションごとにサブクラス」だが、本件は「1クラス × year引数」とする。 +2. **表示ラベルのプレフィックス付与(DB値は不変)**: DB の `task_table_index`(= AOJ 問題ID、例 `1664`)を数値として昇順ソートし、最小を `A`, 次を `B`… に対応付け、表示タイトルを `(問題名)` → `A. (問題名)` に前置変換。ジャッジの欠落で連番にならない年度は **contest_id ごとの上書き Map** で個別対応(Map の中身は後でユーザーが提供)。 + - **重要**: ICPC の DB タイトルは `"{name}"` のみ(`"{problem_index}. {name}"` ではない)。ICPC はアプリ側からの登録(初期仕様)で title にインデックスが付かなかったため。よって除去処理は不要で、letter を**前置するだけ**でよい(手入力の他 seed データは title にインデックス付きだが、ICPC は別扱い)。 +3. **テーブルタイトルのフォントサイズをこのテーブルだけ小さく**: 現状 `TaskTable.svelte` で `text-2xl` ハードコードのため、**プロバイダ単位で指定可能にする**(このテーブルは `text-xl`)。 + +## 設計判断と既存資産の再利用 + +- **ContestType レイヤはスキップ**: `AOJ_ICPC` は schema enum / `ContestType` 定数 / `classifyContest` / `contestTypePriorities` / `getContestNameLabel` / 分類・ラベルのテスト(`AOJ_ICPC_TEST_DATA`)まで完備。skill の Layer 1〜3 は不要。 +- **プロバイダ基盤の再利用**: `ContestTableProviderBase`(`src/features/tasks/utils/contest-table/contest_table_provider_base.ts`)を継承。`constructor(contestType, section?)` の `section` に年度文字列を渡すと provider key が `AOJ_ICPC::2025` となり一意化できる(`createProviderKey` 既存)。 +- **ラベル付与は generateTable をオーバーライドして表示用 title を差し替える方式**(共有コンポーネント無改造)。ICPC の title は `"{name}"` のみなので除去は不要、`{letter}. ` を**前置するだけ**。`isShownTaskIndex: true` でセルは `taskResult.title` をそのまま表示。 +- **letter 割当はピュアな util に切り出してテスト**(coding-style: business logic → utils + adjacent test)。 +- **seed 行の形式**は既存 AOJ/JAG 行に準拠: `{ id, contest_id, problem_index, name, title }`、`grade` 省略 = PENDING。ただし **ICPC は `title = "{name}"`**(problem_index プレフィックスなし。アプリ登録の初期仕様に合わせる)。`problem_index` には AOJ id を入れる。 + +### 却下した代替案 + +- **年度ごとにサブクラス(JOI/Tessoku 流)**: 28クラスは冗長。ユーザー要件「ベースクラス1つ + コンストラクタで年度指定」に反する。→ 却下。 +- **セル表示ロジックを `TaskTableBodyCell.svelte` 側に分岐追加**: 共有コンポーネントに ICPC 専用分岐が漏れる。generateTable で表示用 title を生成する方がプロバイダ内に閉じる。→ 却下。 +- **`title` フォントサイズを全テーブル一律変更**: 他テーブルへ影響。→ 却下、プロバイダ単位の任意設定にする。 +- **letter を AOJ API の配列順から決定**: 欠落年度では配列順≠正規レター。`task_table_index` 数値昇順 + 上書き Map の方が DB 単独で完結。→ 数値昇順を採用(ユーザー選択)。 + +--- + +## Phase 0: Seed データ収集(ブロッカー・最初に実施) + +`prisma/tasks.ts` に ICPC 行が0件。AOJ Judge API から収集して追加する。 + +- データ源: `https://judgeapi.u-aizu.ac.jp/challenges/cl/icpc/prelim` + - `contests[].abbr`(例 `ICPCPrelim2025`)、`contests[].days[].problems[]` に `id`(数値文字列)・`name`。 +- 各 problem を1行に変換(`prisma/tasks.ts` の配列へ追記): + ```js + { + id: '1664', + contest_id: 'ICPCPrelim2023', + problem_index: '1664', // = AOJ id(DB の task_table_index にマップ) + name: '', + title: '', // ICPC は name のみ(index プレフィックスを付けない) + // grade 省略 → PENDING + } + ``` +- 28年分(1998〜2025、各4〜9問、計 ~170行)。 +- 検証: `classifyContest('ICPCPrelim2023') === ContestType.AOJ_ICPC`、`getContestNameLabel` が `(ICPC 国内予選 2023)` 系を返すこと(既存テストの延長で確認可)。 + +> 注: gap(ジャッジ欠落で A,B,C… が連続しない年度)の特定は Phase 2 の上書き Map 用データとして別途ユーザーが提供。Phase 0 では API が返す問題のみ seed する。 + +**critical file**: `prisma/tasks.ts` + +--- + +## Phase 1: タイトルフォントサイズのプロバイダ単位設定(低リスク・独立) + +- `ContestTableMetaData`(`src/features/tasks/types/contest-table/contest_table_provider.ts`)に任意フィールド追加: + ```ts + titleFontSize?: string; // Tailwind class, e.g. 'text-xl'. Defaults to 'text-2xl' at render. + ``` +- `TaskTable.svelte` のタイトル描画を変更(L227 付近): + ```svelte + + ``` +- 既存プロバイダは未指定 → デフォルト `text-2xl` 維持(後方互換)。 + +**critical files**: `contest_table_provider.ts`, `TaskTable.svelte` + +--- + +## Phase 2: letter 割当ユーティリティ(TDD) + +新規 util(feature-scoped): `src/features/tasks/utils/contest-table/aoj_icpc_labels.ts` + +- 上書き Map 定数(初期は空 or 既知の gap 年度のみ。中身は後日ユーザー提供): + ```ts + // contest_id -> (task_table_index -> letter). Used only for years with judge gaps. + export const ICPC_PRELIM_LABEL_OVERRIDES: Record> = { + // e.g. ICPCPrelim2007: { '1150': 'A', '1152': 'C', ... } + }; + ``` +- 関数: + ```ts + // Build task_table_index -> letter map for one contest. + // Default: sort indices numerically asc, assign A, B, C... + // Override: if ICPC_PRELIM_LABEL_OVERRIDES[contestId] exists, use it. + export function buildAojIcpcLetterMap( + contestId: string, + taskTableIndices: string[], + ): Map; + ``` +- 隣接テスト `aoj_icpc_labels.test.ts`: + - 自動連番: `['1665','1664']` → `{1664:'A', 1665:'B'}`(数値昇順)。 + - 上書き Map 経路: フィクスチャ contest_id で Map が優先されること。 + - ICPC 国内予選は最大9問(A〜I)で収まる前提を明記。 + +**critical file**: `aoj_icpc_labels.ts`(+ test) + +--- + +## Phase 3: AojIcpcPrelimProvider(TDD・1クラス×year引数) + +新規 `src/features/tasks/utils/contest-table/aoj_icpc_providers.ts` + +```ts +export class AojIcpcPrelimProvider extends ContestTableProviderBase { + private readonly year: number; + private readonly contestId: string; + + constructor(contestType: ContestType, year: number) { + super(contestType, String(year)); // provider key: AOJ_ICPC::{year} + this.year = year; + this.contestId = `ICPCPrelim${year}`; + } + + protected setFilterCondition() { + return (taskResult: TaskResult) => taskResult.contest_id === this.contestId; + } + + // Override: prepend assigned letter to the title (display only; DB unchanged). + // ICPC titles are stored as "{name}" only, so no prefix removal is needed. + generateTable(filtered: TaskResults): ContestTable { + const letterMap = buildAojIcpcLetterMap( + this.contestId, + filtered.map((taskResult) => taskResult.task_table_index), + ); + const table: ContestTable = { [this.contestId]: {} }; + + for (const taskResult of filtered) { + const index = taskResult.task_table_index; + const letter = letterMap.get(index) ?? index; + table[this.contestId][index] = { ...taskResult, title: `${letter}. ${taskResult.title}` }; + } + + return table; + } + + // Ensure left-to-right cell order is numeric (A,B,C...). Safeguard for variable-width ids. + getHeaderIdsForTask(filtered: TaskResults): string[] { + return Array.from(new Set(filtered.map((taskResult) => taskResult.task_table_index))).sort( + (first, second) => Number(first) - Number(second), + ); + } + + getMetadata(): ContestTableMetaData { + return { + title: `ICPC 国内予選 ${this.year}`, + abbreviationName: `icpcPrelim${this.year}`, + titleFontSize: 'text-xl', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', // EDPC 相当 + isShownTaskIndex: true, // show transformed "{letter}. {name}" as-is + }; + } + + getContestRoundLabel(): string { + return `ICPC 国内予選 ${this.year}`; // unused (isShownRoundLabel: false) but required + } +} +``` + +- 隣接テスト `aoj_icpc_providers.test.ts`(Pattern 2 系・年度フィクスチャで検証): + - `filter`: `ICPCPrelim2023` の行のみ抽出。 + - `generateTable`: title が `A. ...` / `B. ...` に置換(DB の `task_table_index`/`task_id` は不変)。 + - `getMetadata`: `title === 'ICPC 国内予選 2023'`、`titleFontSize === 'text-xl'`。 + - `getDisplayConfig`: `isShownHeader === false` 等。 + - 上書き Map フィクスチャ年度で letter が Map 通りになること。 + +**critical files**: `aoj_icpc_providers.ts`(+ test) + +--- + +## Phase 4: グループ登録(TDD・新規グループ) + +`src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` + +```ts +// Range of ICPC domestic-prelim years available on AOJ. +// OLDEST is a stable domain constant (first season on AOJ). +// LATEST must be bumped when a new season is seeded into prisma/tasks.ts. +const ICPC_PRELIM_OLDEST_YEAR = 1998; +const ICPC_PRELIM_LATEST_YEAR = 2025; + +AojIcpcPrelim: () => { + const group = new ContestTableProviderGroup('ICPC 国内予選', { + buttonLabel: 'ICPC 国内予選', + ariaLabel: 'Filter ICPC Domestic Preliminary', + }); + // Iterate from latest to oldest so the newest year's table renders on top. + for (let year = ICPC_PRELIM_LATEST_YEAR; year >= ICPC_PRELIM_OLDEST_YEAR; year--) { + group.addProvider(new AojIcpcPrelimProvider(ContestType.AOJ_ICPC, year)); + } + return group; +}, +``` + +- `contestTableProviderGroups` に `aojIcpcPrelim: presets.AojIcpcPrelim()` を追加(JOI 群の後ろ=外部プラットフォーム位置)。 +- 定数 `ICPC_PRELIM_OLDEST_YEAR` / `ICPC_PRELIM_LATEST_YEAR` はモジュール冒頭に配置(`prepareContestProviderPresets` の外)。 +- `contest_table_provider_groups.test.ts` 更新: + - 新グループ名 / buttonLabel / ariaLabel。 + - `getSize() === ICPC_PRELIM_LATEST_YEAR - ICPC_PRELIM_OLDEST_YEAR + 1`(= 28)。 + - `getProvider(ContestType.AOJ_ICPC, '2023')` が `AojIcpcPrelimProvider` を返す。 + - import 追加。 +- (任意)drift 検知: seed 中の最新 `ICPCPrelim` 年度が `ICPC_PRELIM_LATEST_YEAR` と一致することを検証するテストを追加すると、seed 追加時の定数未更新を防げる。 + +**critical files**: `contest_table_provider_groups.ts`(+ group test) + +--- + +## Phase 5: 最終検証 + +- `pnpm test:unit`(特に `src/features/tasks/utils/contest-table/`) +- `pnpm check` +- `pnpm lint` / `pnpm format` +- 手動/E2E 確認: + - `pnpm dev` → タスク一覧でボタン「ICPC 国内予選」を選択。 + - 年度テーブルが **2025 → 1998 の順**で縦に並ぶ。 + - 各セルが `A. (問題名)`、`B. (問題名)`… で表示され、タイトル見出しが他テーブルより小さい(`text-xl`)。 + - 外部リンクが AOJ(`judge.u-aizu.ac.jp/.../id={task_id}`)へ遷移。 + +--- + +## 実装メモ + +- コミット粒度: Phase 0(seed)/ Phase 1(font config)/ Phase 2-3(util + provider)/ Phase 4(group)で分割。 +- gap 年度の上書き Map データはユーザー提供待ち。提供後に `ICPC_PRELIM_LABEL_OVERRIDES` を埋め、該当年度のテストを追加。 +- 26問超は ICPC 国内予選では発生しない(最大9問)ため A〜Z 超のロジックは実装しない(YAGNI)。 + +## チェックリスト + +- [ ] Phase 0: AOJ API から seed 収集し `prisma/tasks.ts` に追加(1998〜2025) +- [ ] Phase 1: `titleFontSize` 追加 + `TaskTable.svelte` 反映 +- [ ] Phase 2: `aoj_icpc_labels.ts`(letter util + 上書き Map)+ test +- [ ] Phase 3: `aoj_icpc_providers.ts`(AojIcpcPrelimProvider)+ test +- [ ] Phase 4: グループ登録 + groups test 更新 +- [ ] Phase 5: `pnpm test:unit` / `check` / `lint` / 手動確認 +- [ ] gap 年度 上書き Map(ユーザー提供後) diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 31117ba0b..395864035 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -8704,6 +8704,1315 @@ export const tasks = [ name: 'Driving on a Tree', title: 'D. Driving on a Tree', }, + { + id: '1100', + contest_id: 'ICPCPrelim1998', + problem_index: '1100', + name: 'Area of Polygons', + title: 'Area of Polygons', + }, + { + id: '1101', + contest_id: 'ICPCPrelim1998', + problem_index: '1101', + name: 'A Simple Offline Text Editor', + title: 'A Simple Offline Text Editor', + }, + { + id: '1102', + contest_id: 'ICPCPrelim1998', + problem_index: '1102', + name: 'Calculation of Expressions', + title: 'Calculation of Expressions', + }, + { + id: '1103', + contest_id: 'ICPCPrelim1998', + problem_index: '1103', + name: 'Board Arrangements for Concentration Games', + title: 'Board Arrangements for Concentration Games', + }, + { + id: '1104', + contest_id: 'ICPCPrelim1999', + problem_index: '1104', + name: "Where's Your Robot?", + title: "Where's Your Robot?", + }, + { + id: '1105', + contest_id: 'ICPCPrelim1999', + problem_index: '1105', + name: 'Unable Count', + title: 'Unable Count', + }, + { + id: '1106', + contest_id: 'ICPCPrelim1999', + problem_index: '1106', + name: 'Factorization of Quadratic Formula', + title: 'Factorization of Quadratic Formula', + }, + { + id: '1107', + contest_id: 'ICPCPrelim1999', + problem_index: '1107', + name: 'Spiral Footrace', + title: 'Spiral Footrace', + }, + { + id: '1108', + contest_id: 'ICPCPrelim1999', + problem_index: '1108', + name: 'A Long Ride on a Railway', + title: 'A Long Ride on a Railway', + }, + { + id: '1109', + contest_id: 'ICPCPrelim2000', + problem_index: '1109', + name: "Fermat's Last Theorem", + title: "Fermat's Last Theorem", + }, + { + id: '1110', + contest_id: 'ICPCPrelim2000', + problem_index: '1110', + name: 'Patience', + title: 'Patience', + }, + { + id: '1111', + contest_id: 'ICPCPrelim2000', + problem_index: '1111', + name: 'Cyber Guardian', + title: 'Cyber Guardian', + }, + { + id: '1112', + contest_id: 'ICPCPrelim2000', + problem_index: '1112', + name: 'Strange Key', + title: 'Strange Key', + }, + { + id: '1114', + contest_id: 'ICPCPrelim2001', + problem_index: '1114', + name: 'Get a Rectangular Field', + title: 'Get a Rectangular Field', + }, + { + id: '1115', + contest_id: 'ICPCPrelim2001', + problem_index: '1115', + name: 'Multi-column List', + title: 'Multi-column List', + }, + { + id: '1116', + contest_id: 'ICPCPrelim2001', + problem_index: '1116', + name: 'Jigsaw Puzzles for Computers', + title: 'Jigsaw Puzzles for Computers', + }, + { + id: '1117', + contest_id: 'ICPCPrelim2001', + problem_index: '1117', + name: 'Missing Numbers', + title: 'Missing Numbers', + }, + { + id: '1118', + contest_id: 'ICPCPrelim2001', + problem_index: '1118', + name: 'Nets of Dice', + title: 'Nets of Dice', + }, + { + id: '1119', + contest_id: 'ICPCPrelim2002', + problem_index: '1119', + name: 'Exploring Caves', + title: 'Exploring Caves', + }, + { + id: '1120', + contest_id: 'ICPCPrelim2002', + problem_index: '1120', + name: 'Pile Up!', + title: 'Pile Up!', + }, + { + id: '1121', + contest_id: 'ICPCPrelim2002', + problem_index: '1121', + name: 'Kanglish:Analysis on Artificial Language', + title: 'Kanglish:Analysis on Artificial Language', + }, + { + id: '1122', + contest_id: 'ICPCPrelim2002', + problem_index: '1122', + name: 'What is the Number in my Mind ?', + title: 'What is the Number in my Mind ?', + }, + { + id: '1124', + contest_id: 'ICPCPrelim2003', + problem_index: '1124', + name: 'When Can We Meet?', + title: 'When Can We Meet?', + }, + { + id: '1125', + contest_id: 'ICPCPrelim2003', + problem_index: '1125', + name: 'Get Many Persimmon Trees', + title: 'Get Many Persimmon Trees', + }, + { + id: '1126', + contest_id: 'ICPCPrelim2003', + problem_index: '1126', + name: 'The Secret Number', + title: 'The Secret Number', + }, + { + id: '1127', + contest_id: 'ICPCPrelim2003', + problem_index: '1127', + name: 'Building a Space Station', + title: 'Building a Space Station', + }, + { + id: '1128', + contest_id: 'ICPCPrelim2003', + problem_index: '1128', + name: 'Square Carpets', + title: 'Square Carpets', + }, + { + id: '1129', + contest_id: 'ICPCPrelim2004', + problem_index: '1129', + name: 'Hanafuda Shuffle', + title: 'Hanafuda Shuffle', + }, + { + id: '1130', + contest_id: 'ICPCPrelim2004', + problem_index: '1130', + name: 'Red and Black', + title: 'Red and Black', + }, + { + id: '1131', + contest_id: 'ICPCPrelim2004', + problem_index: '1131', + name: 'Unit Fraction Partition', + title: 'Unit Fraction Partition', + }, + { + id: '1132', + contest_id: 'ICPCPrelim2004', + problem_index: '1132', + name: 'Circle and Points', + title: 'Circle and Points', + }, + { + id: '1133', + contest_id: 'ICPCPrelim2004', + problem_index: '1133', + name: 'Water Tank', + title: 'Water Tank', + }, + { + id: '1134', + contest_id: 'ICPCPrelim2004', + problem_index: '1134', + name: 'Name the Crossing', + title: 'Name the Crossing', + }, + { + id: '1135', + contest_id: 'ICPCPrelim2005', + problem_index: '1135', + name: "Ohgas' Fortune", + title: "Ohgas' Fortune", + }, + { + id: '1136', + contest_id: 'ICPCPrelim2005', + problem_index: '1136', + name: 'Polygonal Line Search', + title: 'Polygonal Line Search', + }, + { + id: '1137', + contest_id: 'ICPCPrelim2005', + problem_index: '1137', + name: 'Numeral System', + title: 'Numeral System', + }, + { + id: '1138', + contest_id: 'ICPCPrelim2005', + problem_index: '1138', + name: 'Traveling by Stagecoach', + title: 'Traveling by Stagecoach', + }, + { + id: '1139', + contest_id: 'ICPCPrelim2005', + problem_index: '1139', + name: 'Earth Observation with a Mobile Robot Team', + title: 'Earth Observation with a Mobile Robot Team', + }, + { + id: '1140', + contest_id: 'ICPCPrelim2005', + problem_index: '1140', + name: 'Cleaning Robot', + title: 'Cleaning Robot', + }, + { + id: '1141', + contest_id: 'ICPCPrelim2006', + problem_index: '1141', + name: "Dirichlet's Theorem on Arithmetic Progressions", + title: "Dirichlet's Theorem on Arithmetic Progressions", + }, + { + id: '1142', + contest_id: 'ICPCPrelim2006', + problem_index: '1142', + name: 'Organize Your Train part II', + title: 'Organize Your Train part II', + }, + { + id: '1143', + contest_id: 'ICPCPrelim2006', + problem_index: '1143', + name: 'Hexerpents of Hexwamp', + title: 'Hexerpents of Hexwamp', + }, + { + id: '1144', + contest_id: 'ICPCPrelim2006', + problem_index: '1144', + name: 'Curling 2.0', + title: 'Curling 2.0', + }, + { + id: '1145', + contest_id: 'ICPCPrelim2006', + problem_index: '1145', + name: 'The Genome Database of All Space Life', + title: 'The Genome Database of All Space Life', + }, + { + id: '1146', + contest_id: 'ICPCPrelim2006', + problem_index: '1146', + name: 'Secrets in Shadows', + title: 'Secrets in Shadows', + }, + { + id: '1147', + contest_id: 'ICPCPrelim2007', + problem_index: '1147', + name: 'ICPC Score Totalizer Software', + title: 'ICPC Score Totalizer Software', + }, + { + id: '1148', + contest_id: 'ICPCPrelim2007', + problem_index: '1148', + name: 'Analyzing Login/Logout Records', + title: 'Analyzing Login/Logout Records', + }, + { + id: '1149', + contest_id: 'ICPCPrelim2007', + problem_index: '1149', + name: 'Cut the Cake', + title: 'Cut the Cake', + }, + { + id: '1150', + contest_id: 'ICPCPrelim2007', + problem_index: '1150', + name: 'Cliff Climbing', + title: 'Cliff Climbing', + }, + { + id: '1151', + contest_id: 'ICPCPrelim2007', + problem_index: '1151', + name: 'Twirl Around', + title: 'Twirl Around', + }, + { + id: '1152', + contest_id: 'ICPCPrelim2007', + problem_index: '1152', + name: 'Dr. Podboq or: How We Became Asymmetric', + title: 'Dr. Podboq or: How We Became Asymmetric', + }, + { + id: '1153', + contest_id: 'ICPCPrelim2008', + problem_index: '1153', + name: 'Equal Total Scores', + title: 'Equal Total Scores', + }, + { + id: '1154', + contest_id: 'ICPCPrelim2008', + problem_index: '1154', + name: 'Monday-Saturday Prime Factors', + title: 'Monday-Saturday Prime Factors', + }, + { + id: '1155', + contest_id: 'ICPCPrelim2008', + problem_index: '1155', + name: 'How can I satisfy thee? Let me count the ways...', + title: 'How can I satisfy thee? Let me count the ways...', + }, + { + id: '1156', + contest_id: 'ICPCPrelim2008', + problem_index: '1156', + name: 'Twirling Robot', + title: 'Twirling Robot', + }, + { + id: '1157', + contest_id: 'ICPCPrelim2008', + problem_index: '1157', + name: 'Roll-A-Big-Ball', + title: 'Roll-A-Big-Ball', + }, + { + id: '1158', + contest_id: 'ICPCPrelim2008', + problem_index: '1158', + name: 'ICPC: Intelligent Congruent Partition of Chocolate', + title: 'ICPC: Intelligent Congruent Partition of Chocolate', + }, + { + id: '1159', + contest_id: 'ICPCPrelim2009', + problem_index: '1159', + name: 'Next Mayor', + title: 'Next Mayor', + }, + { + id: '1160', + contest_id: 'ICPCPrelim2009', + problem_index: '1160', + name: 'How Many Islands?', + title: 'How Many Islands?', + }, + { + id: '1161', + contest_id: 'ICPCPrelim2009', + problem_index: '1161', + name: 'Verbal Arithmetic', + title: 'Verbal Arithmetic', + }, + { + id: '1162', + contest_id: 'ICPCPrelim2009', + problem_index: '1162', + name: 'Discrete Speed', + title: 'Discrete Speed', + }, + { + id: '1163', + contest_id: 'ICPCPrelim2009', + problem_index: '1163', + name: 'Cards', + title: 'Cards', + }, + { + id: '1164', + contest_id: 'ICPCPrelim2009', + problem_index: '1164', + name: 'Tighten Up!', + title: 'Tighten Up!', + }, + { + id: '1165', + contest_id: 'ICPCPrelim2010', + problem_index: '1165', + name: "Pablo Squarson's Headache", + title: "Pablo Squarson's Headache", + }, + { + id: '1166', + contest_id: 'ICPCPrelim2010', + problem_index: '1166', + name: 'Amazing Mazes', + title: 'Amazing Mazes', + }, + { + id: '1167', + contest_id: 'ICPCPrelim2010', + problem_index: '1167', + name: "Pollock's conjecture", + title: "Pollock's conjecture", + }, + { + id: '1168', + contest_id: 'ICPCPrelim2010', + problem_index: '1168', + name: 'Off Balance', + title: 'Off Balance', + }, + { + id: '1169', + contest_id: 'ICPCPrelim2010', + problem_index: '1169', + name: 'The Most Powerful Spell', + title: 'The Most Powerful Spell', + }, + { + id: '1170', + contest_id: 'ICPCPrelim2010', + problem_index: '1170', + name: 'Old Memories', + title: 'Old Memories', + }, + { + id: '1171', + contest_id: 'ICPCPrelim2010', + problem_index: '1171', + name: 'Laser Beam Reflections', + title: 'Laser Beam Reflections', + }, + { + id: '1172', + contest_id: 'ICPCPrelim2011', + problem_index: '1172', + name: "Chebyshev's Theorem", + title: "Chebyshev's Theorem", + }, + { + id: '1173', + contest_id: 'ICPCPrelim2011', + problem_index: '1173', + name: 'The Balance of the World', + title: 'The Balance of the World', + }, + { + id: '1174', + contest_id: 'ICPCPrelim2011', + problem_index: '1174', + name: 'Identically Colored Panels Connection', + title: 'Identically Colored Panels Connection', + }, + { + id: '1175', + contest_id: 'ICPCPrelim2011', + problem_index: '1175', + name: 'And Then. How Many Are There?', + title: 'And Then. How Many Are There?', + }, + { + id: '1176', + contest_id: 'ICPCPrelim2011', + problem_index: '1176', + name: 'Planning Rolling Blackouts', + title: 'Planning Rolling Blackouts', + }, + { + id: '1177', + contest_id: 'ICPCPrelim2011', + problem_index: '1177', + name: 'Watchdog Corporation', + title: 'Watchdog Corporation', + }, + { + id: '1178', + contest_id: 'ICPCPrelim2011', + problem_index: '1178', + name: 'A Broken Door', + title: 'A Broken Door', + }, + { + id: '1179', + contest_id: 'ICPCPrelim2012', + problem_index: '1179', + name: 'Millennium', + title: 'Millennium', + }, + { + id: '1180', + contest_id: 'ICPCPrelim2012', + problem_index: '1180', + name: 'Recurring Decimals', + title: 'Recurring Decimals', + }, + { + id: '1181', + contest_id: 'ICPCPrelim2012', + problem_index: '1181', + name: 'Biased Dice', + title: 'Biased Dice', + }, + { + id: '1182', + contest_id: 'ICPCPrelim2012', + problem_index: '1182', + name: 'Railway Connection', + title: 'Railway Connection', + }, + { + id: '1183', + contest_id: 'ICPCPrelim2012', + problem_index: '1183', + name: 'Chain-Confined Path', + title: 'Chain-Confined Path', + }, + { + id: '1184', + contest_id: 'ICPCPrelim2012', + problem_index: '1184', + name: 'Generic Poker', + title: 'Generic Poker', + }, + { + id: '1185', + contest_id: 'ICPCPrelim2012', + problem_index: '1185', + name: 'Patisserie ACM', + title: 'Patisserie ACM', + }, + { + id: '1186', + contest_id: 'ICPCPrelim2013', + problem_index: '1186', + name: 'Integral Rectangles', + title: 'Integral Rectangles', + }, + { + id: '1187', + contest_id: 'ICPCPrelim2013', + problem_index: '1187', + name: 'ICPC Ranking', + title: 'ICPC Ranking', + }, + { + id: '1188', + contest_id: 'ICPCPrelim2013', + problem_index: '1188', + name: 'Hierarchical Democracy', + title: 'Hierarchical Democracy', + }, + { + id: '1189', + contest_id: 'ICPCPrelim2013', + problem_index: '1189', + name: 'Prime Caves', + title: 'Prime Caves', + }, + { + id: '1190', + contest_id: 'ICPCPrelim2013', + problem_index: '1190', + name: 'Anchored Balloon', + title: 'Anchored Balloon', + }, + { + id: '1191', + contest_id: 'ICPCPrelim2013', + problem_index: '1191', + name: 'Rotate and Rewrite', + title: 'Rotate and Rewrite', + }, + { + id: '1192', + contest_id: 'ICPCPrelim2014', + problem_index: '1192', + name: 'Tax Rate Changed', + title: 'Tax Rate Changed', + }, + { + id: '1193', + contest_id: 'ICPCPrelim2014', + problem_index: '1193', + name: 'Chain Disappearance Puzzle', + title: 'Chain Disappearance Puzzle', + }, + { + id: '1194', + contest_id: 'ICPCPrelim2014', + problem_index: '1194', + name: 'Vampire', + title: 'Vampire', + }, + { + id: '1195', + contest_id: 'ICPCPrelim2014', + problem_index: '1195', + name: 'Encryption System', + title: 'Encryption System', + }, + { + id: '1196', + contest_id: 'ICPCPrelim2014', + problem_index: '1196', + name: 'Bridge Removal', + title: 'Bridge Removal', + }, + { + id: '1197', + contest_id: 'ICPCPrelim2014', + problem_index: '1197', + name: 'A Die Maker', + title: 'A Die Maker', + }, + { + id: '1198', + contest_id: 'ICPCPrelim2014', + problem_index: '1198', + name: "Don't Cross the Circles!", + title: "Don't Cross the Circles!", + }, + { + id: '1600', + contest_id: 'ICPCPrelim2015', + problem_index: '1600', + name: 'Entrance Examination', + title: 'Entrance Examination', + }, + { + id: '1601', + contest_id: 'ICPCPrelim2015', + problem_index: '1601', + name: 'Short Phrase', + title: 'Short Phrase', + }, + { + id: '1602', + contest_id: 'ICPCPrelim2015', + problem_index: '1602', + name: 'ICPC Calculator', + title: 'ICPC Calculator', + }, + { + id: '1603', + contest_id: 'ICPCPrelim2015', + problem_index: '1603', + name: '500-yen Saving', + title: '500-yen Saving', + }, + { + id: '1604', + contest_id: 'ICPCPrelim2015', + problem_index: '1604', + name: 'Deadlock Detection', + title: 'Deadlock Detection', + }, + { + id: '1605', + contest_id: 'ICPCPrelim2015', + problem_index: '1605', + name: 'Bridge Construction Planning', + title: 'Bridge Construction Planning', + }, + { + id: '1606', + contest_id: 'ICPCPrelim2015', + problem_index: '1606', + name: 'Complex Paper Folding', + title: 'Complex Paper Folding', + }, + { + id: '1607', + contest_id: 'ICPCPrelim2015', + problem_index: '1607', + name: 'Development of Small Flying Robots', + title: 'Development of Small Flying Robots', + }, + { + id: '1608', + contest_id: 'ICPCPrelim2016', + problem_index: '1608', + name: 'Selection of Participants of an Experiment', + title: 'Selection of Participants of an Experiment', + }, + { + id: '1609', + contest_id: 'ICPCPrelim2016', + problem_index: '1609', + name: 'Look for the Winner!', + title: 'Look for the Winner!', + }, + { + id: '1610', + contest_id: 'ICPCPrelim2016', + problem_index: '1610', + name: 'Bamboo Blossoms', + title: 'Bamboo Blossoms', + }, + { + id: '1611', + contest_id: 'ICPCPrelim2016', + problem_index: '1611', + name: 'Daruma Otoshi', + title: 'Daruma Otoshi', + }, + { + id: '1612', + contest_id: 'ICPCPrelim2016', + problem_index: '1612', + name: '3D Printing', + title: '3D Printing', + }, + { + id: '1613', + contest_id: 'ICPCPrelim2016', + problem_index: '1613', + name: 'Deciphering Characters', + title: 'Deciphering Characters', + }, + { + id: '1614', + contest_id: 'ICPCPrelim2016', + problem_index: '1614', + name: 'Warp Drive', + title: 'Warp Drive', + }, + { + id: '1615', + contest_id: 'ICPCPrelim2016', + problem_index: '1615', + name: 'Gift Exchange Party', + title: 'Gift Exchange Party', + }, + { + id: '1616', + contest_id: 'ICPCPrelim2017', + problem_index: '1616', + name: "Taro's Shopping", + title: "Taro's Shopping", + }, + { + id: '1617', + contest_id: 'ICPCPrelim2017', + problem_index: '1617', + name: 'Almost Identical Programs', + title: 'Almost Identical Programs', + }, + { + id: '1618', + contest_id: 'ICPCPrelim2017', + problem_index: '1618', + name: 'A Garden with Ponds', + title: 'A Garden with Ponds', + }, + { + id: '1619', + contest_id: 'ICPCPrelim2017', + problem_index: '1619', + name: 'Making Lunch Boxes', + title: 'Making Lunch Boxes', + }, + { + id: '1620', + contest_id: 'ICPCPrelim2017', + problem_index: '1620', + name: 'Boolean Expression Compressor', + title: 'Boolean Expression Compressor', + }, + { + id: '1621', + contest_id: 'ICPCPrelim2017', + problem_index: '1621', + name: 'Folding a Ribbon', + title: 'Folding a Ribbon', + }, + { + id: '1622', + contest_id: 'ICPCPrelim2017', + problem_index: '1622', + name: 'Go around the Labyrinth', + title: 'Go around the Labyrinth', + }, + { + id: '1623', + contest_id: 'ICPCPrelim2017', + problem_index: '1623', + name: 'Equivalent Deformation', + title: 'Equivalent Deformation', + }, + { + id: '1624', + contest_id: 'ICPCPrelim2018', + problem_index: '1624', + name: 'Income Inequality', + title: 'Income Inequality', + }, + { + id: '1625', + contest_id: 'ICPCPrelim2018', + problem_index: '1625', + name: 'Origami, or the art of folding paper', + title: 'Origami, or the art of folding paper', + }, + { + id: '1626', + contest_id: 'ICPCPrelim2018', + problem_index: '1626', + name: 'Skyscraper MinatoHarukas', + title: 'Skyscraper MinatoHarukas', + }, + { + id: '1627', + contest_id: 'ICPCPrelim2018', + problem_index: '1627', + name: 'Playoff by all the teams', + title: 'Playoff by all the teams', + }, + { + id: '1628', + contest_id: 'ICPCPrelim2018', + problem_index: '1628', + name: 'Floating-Point Numbers', + title: 'Floating-Point Numbers', + }, + { + id: '1629', + contest_id: 'ICPCPrelim2018', + problem_index: '1629', + name: 'Equilateral Triangular Fence', + title: 'Equilateral Triangular Fence', + }, + { + id: '1630', + contest_id: 'ICPCPrelim2018', + problem_index: '1630', + name: 'Expression Mining', + title: 'Expression Mining', + }, + { + id: '1631', + contest_id: 'ICPCPrelim2018', + problem_index: '1631', + name: 'For Programming Excellence', + title: 'For Programming Excellence', + }, + { + id: '1632', + contest_id: 'ICPCPrelim2019', + problem_index: '1632', + name: 'Scores of Final Examination', + title: 'Scores of Final Examination', + }, + { + id: '1633', + contest_id: 'ICPCPrelim2019', + problem_index: '1633', + name: 'On-Screen Keyboard', + title: 'On-Screen Keyboard', + }, + { + id: '1634', + contest_id: 'ICPCPrelim2019', + problem_index: '1634', + name: 'Balance Scale', + title: 'Balance Scale', + }, + { + id: '1635', + contest_id: 'ICPCPrelim2019', + problem_index: '1635', + name: 'Tally Counters', + title: 'Tally Counters', + }, + { + id: '1636', + contest_id: 'ICPCPrelim2019', + problem_index: '1636', + name: 'Cube Surface Puzzle', + title: 'Cube Surface Puzzle', + }, + { + id: '1637', + contest_id: 'ICPCPrelim2019', + problem_index: '1637', + name: 'Flipping Colors', + title: 'Flipping Colors', + }, + { + id: '1638', + contest_id: 'ICPCPrelim2019', + problem_index: '1638', + name: "Let's Move Tiles!", + title: "Let's Move Tiles!", + }, + { + id: '1639', + contest_id: 'ICPCPrelim2019', + problem_index: '1639', + name: 'Addition on Convex Polygons', + title: 'Addition on Convex Polygons', + }, + { + id: '1640', + contest_id: 'ICPCPrelim2020', + problem_index: '1640', + name: 'Count Up 2020', + title: 'Count Up 2020', + }, + { + id: '1641', + contest_id: 'ICPCPrelim2020', + problem_index: '1641', + name: 'Contact Tracer', + title: 'Contact Tracer', + }, + { + id: '1642', + contest_id: 'ICPCPrelim2020', + problem_index: '1642', + name: 'Luggage', + title: 'Luggage', + }, + { + id: '1643', + contest_id: 'ICPCPrelim2020', + problem_index: '1643', + name: 'Icpcan Alphabet', + title: 'Icpcan Alphabet', + }, + { + id: '1644', + contest_id: 'ICPCPrelim2020', + problem_index: '1644', + name: 'Keima', + title: 'Keima', + }, + { + id: '1645', + contest_id: 'ICPCPrelim2020', + problem_index: '1645', + name: 'Evacuation Site', + title: 'Evacuation Site', + }, + { + id: '1646', + contest_id: 'ICPCPrelim2020', + problem_index: '1646', + name: 'Avoiding Three Cs', + title: 'Avoiding Three Cs', + }, + { + id: '1647', + contest_id: 'ICPCPrelim2020', + problem_index: '1647', + name: 'Idealistic Canister', + title: 'Idealistic Canister', + }, + { + id: '1648', + contest_id: 'ICPCPrelim2021', + problem_index: '1648', + name: 'Marbles Tell Your Lucky Number', + title: 'Marbles Tell Your Lucky Number', + }, + { + id: '1649', + contest_id: 'ICPCPrelim2021', + problem_index: '1649', + name: 'Hundred-Cell Calculation Puzzles', + title: 'Hundred-Cell Calculation Puzzles', + }, + { + id: '1650', + contest_id: 'ICPCPrelim2021', + problem_index: '1650', + name: 'Tree Transformation Puzzle', + title: 'Tree Transformation Puzzle', + }, + { + id: '1651', + contest_id: 'ICPCPrelim2021', + problem_index: '1651', + name: 'Handing out Balloons', + title: 'Handing out Balloons', + }, + { + id: '1652', + contest_id: 'ICPCPrelim2021', + problem_index: '1652', + name: 'Time is Money', + title: 'Time is Money', + }, + { + id: '1653', + contest_id: 'ICPCPrelim2021', + problem_index: '1653', + name: "Princess' Perfectionism", + title: "Princess' Perfectionism", + }, + { + id: '1654', + contest_id: 'ICPCPrelim2021', + problem_index: '1654', + name: 'Positioning the Lights', + title: 'Positioning the Lights', + }, + { + id: '1655', + contest_id: 'ICPCPrelim2021', + problem_index: '1655', + name: 'Ninja Escape', + title: 'Ninja Escape', + }, + { + id: '1656', + contest_id: 'ICPCPrelim2022', + problem_index: '1656', + name: 'Counting Peaks of Infection', + title: 'Counting Peaks of Infection', + }, + { + id: '1657', + contest_id: 'ICPCPrelim2022', + problem_index: '1657', + name: 'Leave No One Behind', + title: 'Leave No One Behind', + }, + { + id: '1658', + contest_id: 'ICPCPrelim2022', + problem_index: '1658', + name: 'Training Schedule for ICPC', + title: 'Training Schedule for ICPC', + }, + { + id: '1659', + contest_id: 'ICPCPrelim2022', + problem_index: '1659', + name: 'Audience Queue', + title: 'Audience Queue', + }, + { + id: '1660', + contest_id: 'ICPCPrelim2022', + problem_index: '1660', + name: 'Village of Lore', + title: 'Village of Lore', + }, + { + id: '1661', + contest_id: 'ICPCPrelim2022', + problem_index: '1661', + name: 'Trim It Step by Step', + title: 'Trim It Step by Step', + }, + { + id: '1662', + contest_id: 'ICPCPrelim2022', + problem_index: '1662', + name: 'Keep in Touch', + title: 'Keep in Touch', + }, + { + id: '1663', + contest_id: 'ICPCPrelim2022', + problem_index: '1663', + name: 'Artist in Agony', + title: 'Artist in Agony', + }, + { + id: '1664', + contest_id: 'ICPCPrelim2023', + problem_index: '1664', + name: 'Which Team Should Receive the Sponsor Prize?', + title: 'Which Team Should Receive the Sponsor Prize?', + }, + { + id: '1665', + contest_id: 'ICPCPrelim2023', + problem_index: '1665', + name: 'Amidakuji', + title: 'Amidakuji', + }, + { + id: '1666', + contest_id: 'ICPCPrelim2023', + problem_index: '1666', + name: 'Changing the Sitting Arrangement', + title: 'Changing the Sitting Arrangement', + }, + { + id: '1667', + contest_id: 'ICPCPrelim2023', + problem_index: '1667', + name: 'Efficient Problem Set', + title: 'Efficient Problem Set', + }, + { + id: '1668', + contest_id: 'ICPCPrelim2023', + problem_index: '1668', + name: 'Tampered Records', + title: 'Tampered Records', + }, + { + id: '1669', + contest_id: 'ICPCPrelim2023', + problem_index: '1669', + name: 'Villa of Emblem Shape', + title: 'Villa of Emblem Shape', + }, + { + id: '1670', + contest_id: 'ICPCPrelim2023', + problem_index: '1670', + name: 'Fair Deal of Dice', + title: 'Fair Deal of Dice', + }, + { + id: '1671', + contest_id: 'ICPCPrelim2023', + problem_index: '1671', + name: 'Planning Locations of Bus Stops', + title: 'Planning Locations of Bus Stops', + }, + { + id: '1672', + contest_id: 'ICPCPrelim2024', + problem_index: '1672', + name: 'Snacks within 300 Yen', + title: 'Snacks within 300 Yen', + }, + { + id: '1673', + contest_id: 'ICPCPrelim2024', + problem_index: '1673', + name: 'Overtaking', + title: 'Overtaking', + }, + { + id: '1674', + contest_id: 'ICPCPrelim2024', + problem_index: '1674', + name: 'Honeycomb Distance', + title: 'Honeycomb Distance', + }, + { + id: '1675', + contest_id: 'ICPCPrelim2024', + problem_index: '1675', + name: "A Bug That's Not a Pill Bug", + title: "A Bug That's Not a Pill Bug", + }, + { + id: '1676', + contest_id: 'ICPCPrelim2024', + problem_index: '1676', + name: 'Colorful Residential Area', + title: 'Colorful Residential Area', + }, + { + id: '1677', + contest_id: 'ICPCPrelim2024', + problem_index: '1677', + name: 'Billiards', + title: 'Billiards', + }, + { + id: '1678', + contest_id: 'ICPCPrelim2024', + problem_index: '1678', + name: 'Two Sets of Cards', + title: 'Two Sets of Cards', + }, + { + id: '1679', + contest_id: 'ICPCPrelim2024', + problem_index: '1679', + name: 'Blocking the Way', + title: 'Blocking the Way', + }, + { + id: '1680', + contest_id: 'ICPCPrelim2024', + problem_index: '1680', + name: 'All Survived?', + title: 'All Survived?', + }, + { + id: '1681', + contest_id: 'ICPCPrelim2025', + problem_index: '1681', + name: '2025', + title: '2025', + }, + { + id: '1682', + contest_id: 'ICPCPrelim2025', + problem_index: '1682', + name: 'Prefix and Suffix Can Be the Same', + title: 'Prefix and Suffix Can Be the Same', + }, + { + id: '1683', + contest_id: 'ICPCPrelim2025', + problem_index: '1683', + name: 'Calendar of an Enthusiastic Worker', + title: 'Calendar of an Enthusiastic Worker', + }, + { + id: '1684', + contest_id: 'ICPCPrelim2025', + problem_index: '1684', + name: 'Ancient Game Board', + title: 'Ancient Game Board', + }, + { + id: '1685', + contest_id: 'ICPCPrelim2025', + problem_index: '1685', + name: 'To Be Discontinued', + title: 'To Be Discontinued', + }, + { + id: '1686', + contest_id: 'ICPCPrelim2025', + problem_index: '1686', + name: 'Dog Tricks', + title: 'Dog Tricks', + }, + { + id: '1687', + contest_id: 'ICPCPrelim2025', + problem_index: '1687', + name: 'Number of Faces', + title: 'Number of Faces', + }, + { + id: '1688', + contest_id: 'ICPCPrelim2025', + problem_index: '1688', + name: 'Parentheses', + title: 'Parentheses', + }, + { + id: '1689', + contest_id: 'ICPCPrelim2025', + problem_index: '1689', + name: 'Preparing the Lunch', + title: 'Preparing the Lunch', + }, { id: '2389', contest_id: 'JAGSpring2012', diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index 821491b79..c3048c395 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -224,7 +224,10 @@ {@const contestTable = getTaskTable(metadata.abbreviationName)} - + {metadata.title} diff --git a/src/features/tasks/types/contest-table/contest_table_provider.ts b/src/features/tasks/types/contest-table/contest_table_provider.ts index 19fa3f6f9..cd174e64a 100644 --- a/src/features/tasks/types/contest-table/contest_table_provider.ts +++ b/src/features/tasks/types/contest-table/contest_table_provider.ts @@ -127,10 +127,12 @@ export type ContestTable = Record>; * @typedef {Object} ContestTableMetaData * @property {string} title - The title text to display for the contest table. * @property {string} abbreviationName - Contest abbreviation, used for map keys. + * @property {string} [titleFontSize] - Tailwind class for title font size, e.g. 'text-xl'. Defaults to 'text-2xl' at render. */ export type ContestTableMetaData = { title: string; abbreviationName: string; + titleFontSize?: string; }; /** diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts new file mode 100644 index 000000000..5e746851d --- /dev/null +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; + +import { buildAojIcpcLetterMap, ICPC_PRELIM_LABEL_OVERRIDES } from './aoj_icpc_labels'; + +describe('buildAojIcpcLetterMap', () => { + test('sorts indices numerically ascending and assigns letters A, B, C...', () => { + const result = buildAojIcpcLetterMap('ICPCPrelim2023', ['1665', '1664']); + + expect(result.get('1664')).toBe('A'); + expect(result.get('1665')).toBe('B'); + expect(result.size).toBe(2); + }); + + test('returns empty Map for empty input', () => { + const result = buildAojIcpcLetterMap('ICPCPrelim2023', []); + + expect(result.size).toBe(0); + }); + + test('returns Map with single entry assigned letter A', () => { + const result = buildAojIcpcLetterMap('ICPCPrelim2023', ['1000']); + + expect(result.get('1000')).toBe('A'); + expect(result.size).toBe(1); + }); + + describe('override path', () => { + beforeEach(() => { + ICPC_PRELIM_LABEL_OVERRIDES['ICPCPrelimTest'] = { + '1150': 'A', + '1152': 'C', + '1155': 'E', + }; + }); + + afterEach(() => { + delete ICPC_PRELIM_LABEL_OVERRIDES['ICPCPrelimTest']; + }); + + test('uses override map when contest_id has an entry in ICPC_PRELIM_LABEL_OVERRIDES', () => { + const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); + + expect(result.get('1150')).toBe('A'); + expect(result.get('1152')).toBe('C'); + expect(result.get('1155')).toBe('E'); + expect(result.size).toBe(3); + }); + + test('ignores the input indices and uses only the override map entries', () => { + const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); + + expect(result.has('1151')).toBe(false); + expect(result.has('1153')).toBe(false); + }); + }); +}); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts new file mode 100644 index 000000000..97ce21e12 --- /dev/null +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts @@ -0,0 +1,25 @@ +// contest_id -> (task_table_index -> letter). Used only for years with judge gaps. +export const ICPC_PRELIM_LABEL_OVERRIDES: Record> = {}; + +// Build task_table_index -> letter map for one contest. +// Default: sort indices numerically asc, assign A, B, C... +// Override: if ICPC_PRELIM_LABEL_OVERRIDES[contestId] exists, use it. +export function buildAojIcpcLetterMap( + contestId: string, + taskTableIndices: string[], +): Map { + const override = ICPC_PRELIM_LABEL_OVERRIDES[contestId]; + + if (override !== undefined) { + return new Map(Object.entries(override)); + } + + const sorted = [...taskTableIndices].sort((first, second) => Number(first) - Number(second)); + const map = new Map(); + + for (let i = 0; i < sorted.length; i++) { + map.set(sorted[i], String.fromCharCode(65 + i)); + } + + return map; +} diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts new file mode 100644 index 000000000..78ae26930 --- /dev/null +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts @@ -0,0 +1,391 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; + +import { ContestType } from '$lib/types/contest'; +import type { TaskResults } from '$lib/types/task'; + +import { ICPC_PRELIM_LABEL_OVERRIDES } from './aoj_icpc_labels'; +import { AojIcpcPrelimProvider } from './aoj_icpc_providers'; + +const createProvider = (year: number) => new AojIcpcPrelimProvider(ContestType.AOJ_ICPC, year); + +// ICPCPrelim1998: 4 problems (A–D), oldest year on AOJ +const tasks1998: TaskResults = [ + { + contest_id: 'ICPCPrelim1998', + task_id: '1100', + task_table_index: '1100', + title: 'Area of Polygons', + }, + { + contest_id: 'ICPCPrelim1998', + task_id: '1101', + task_table_index: '1101', + title: 'A Simple Offline Text Editor', + }, + { + contest_id: 'ICPCPrelim1998', + task_id: '1102', + task_table_index: '1102', + title: 'Calculation of Expressions', + }, + { + contest_id: 'ICPCPrelim1998', + task_id: '1103', + task_table_index: '1103', + title: 'Board Arrangements for Concentration Games', + }, +] as TaskResults; + +// ICPCPrelim2023: 8 problems (A–H), used as the primary fixture +const tasks2023: TaskResults = [ + { + contest_id: 'ICPCPrelim2023', + task_id: '1664', + task_table_index: '1664', + title: 'Which Team Should Receive the Sponsor Prize?', + }, + { contest_id: 'ICPCPrelim2023', task_id: '1665', task_table_index: '1665', title: 'Amidakuji' }, + { + contest_id: 'ICPCPrelim2023', + task_id: '1666', + task_table_index: '1666', + title: 'Changing the Sitting Arrangement', + }, + { + contest_id: 'ICPCPrelim2023', + task_id: '1667', + task_table_index: '1667', + title: 'Efficient Problem Set', + }, + { + contest_id: 'ICPCPrelim2023', + task_id: '1668', + task_table_index: '1668', + title: 'Tampered Records', + }, + { + contest_id: 'ICPCPrelim2023', + task_id: '1669', + task_table_index: '1669', + title: 'Villa of Emblem Shape', + }, + { + contest_id: 'ICPCPrelim2023', + task_id: '1670', + task_table_index: '1670', + title: 'Fair Deal of Dice', + }, + { + contest_id: 'ICPCPrelim2023', + task_id: '1671', + task_table_index: '1671', + title: 'Planning Locations of Bus Stops', + }, +] as TaskResults; + +// ICPCPrelim2025: 9 problems (A–I), latest year and maximum problem count +const tasks2025: TaskResults = [ + { contest_id: 'ICPCPrelim2025', task_id: '1681', task_table_index: '1681', title: '2025' }, + { + contest_id: 'ICPCPrelim2025', + task_id: '1682', + task_table_index: '1682', + title: 'Prefix and Suffix Can Be the Same', + }, + { + contest_id: 'ICPCPrelim2025', + task_id: '1683', + task_table_index: '1683', + title: 'Calendar of an Enthusiastic Worker', + }, + { + contest_id: 'ICPCPrelim2025', + task_id: '1684', + task_table_index: '1684', + title: 'Ancient Game Board', + }, + { + contest_id: 'ICPCPrelim2025', + task_id: '1685', + task_table_index: '1685', + title: 'To Be Discontinued', + }, + { contest_id: 'ICPCPrelim2025', task_id: '1686', task_table_index: '1686', title: 'Dog Tricks' }, + { + contest_id: 'ICPCPrelim2025', + task_id: '1687', + task_table_index: '1687', + title: 'Number of Faces', + }, + { contest_id: 'ICPCPrelim2025', task_id: '1688', task_table_index: '1688', title: 'Parentheses' }, + { + contest_id: 'ICPCPrelim2025', + task_id: '1689', + task_table_index: '1689', + title: 'Preparing the Lunch', + }, +] as TaskResults; + +const mixedTasks: TaskResults = [ + ...tasks2023, + // ICPCPrelim2022: one problem to verify year filtering + { + contest_id: 'ICPCPrelim2022', + task_id: '1656', + task_table_index: '1656', + title: 'Counting Peaks of Infection', + }, + // Non-ICPC contest, to verify contest-type filtering + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A', title: 'Divisor' }, +] as TaskResults; + +describe('AojIcpcPrelimProvider', () => { + // Shared immutable instance for the primary test year — no beforeEach needed + const provider2023 = createProvider(2023); + + describe('filter', () => { + describe('successful cases', () => { + test('returns only tasks belonging to the given year contest', () => { + const filtered = provider2023.filter(mixedTasks); + + expect(filtered).toHaveLength(8); + expect(filtered.every((task) => task.contest_id === 'ICPCPrelim2023')).toBe(true); + }); + + test('excludes tasks from other ICPC years', () => { + const filtered = provider2023.filter(mixedTasks); + + expect(filtered.some((task) => task.contest_id === 'ICPCPrelim2022')).toBe(false); + }); + + test('excludes tasks from non-ICPC contests', () => { + const filtered = provider2023.filter(mixedTasks); + + expect(filtered.some((task) => task.contest_id === 'abc123')).toBe(false); + }); + }); + + describe('edge cases', () => { + test('returns empty array for empty input', () => { + expect(provider2023.filter([] as TaskResults)).toEqual([]); + }); + + test('returns empty array when no tasks match the given year', () => { + const provider2024 = createProvider(2024); + + expect(provider2024.filter(tasks2023)).toEqual([]); + }); + }); + }); + + describe('generateTable', () => { + describe('successful cases', () => { + test('assigns letter prefix A–H to all 8 titles in numeric ID order', () => { + const table = provider2023.generateTable(tasks2023); + + expect(table['ICPCPrelim2023']['1664'].title).toBe( + 'A. Which Team Should Receive the Sponsor Prize?', + ); + expect(table['ICPCPrelim2023']['1665'].title).toBe('B. Amidakuji'); + expect(table['ICPCPrelim2023']['1666'].title).toBe('C. Changing the Sitting Arrangement'); + expect(table['ICPCPrelim2023']['1667'].title).toBe('D. Efficient Problem Set'); + expect(table['ICPCPrelim2023']['1668'].title).toBe('E. Tampered Records'); + expect(table['ICPCPrelim2023']['1669'].title).toBe('F. Villa of Emblem Shape'); + expect(table['ICPCPrelim2023']['1670'].title).toBe('G. Fair Deal of Dice'); + expect(table['ICPCPrelim2023']['1671'].title).toBe('H. Planning Locations of Bus Stops'); + }); + + test('uses task_table_index as the inner key', () => { + const table = provider2023.generateTable(tasks2023); + + expect(Object.keys(table['ICPCPrelim2023'])).toEqual( + expect.arrayContaining(['1664', '1665', '1666', '1667', '1668', '1669', '1670', '1671']), + ); + }); + + test('creates table keyed by contest_id', () => { + const table = provider2023.generateTable(tasks2023); + + expect(Object.keys(table)).toEqual(['ICPCPrelim2023']); + }); + + test('does not mutate original task data', () => { + const originalTitle = tasks2023[0].title; + provider2023.generateTable(tasks2023); + + expect(tasks2023[0].title).toBe(originalTitle); + }); + }); + + describe('edge cases', () => { + test('returns empty inner object when given empty input', () => { + const table = provider2023.generateTable([] as TaskResults); + + expect(table).toEqual({ ICPCPrelim2023: {} }); + }); + }); + }); + + describe('getMetadata', () => { + test('returns correct title with year', () => { + expect(provider2023.getMetadata().title).toBe('ICPC 国内予選 2023'); + }); + + test('returns correct abbreviationName with year', () => { + expect(provider2023.getMetadata().abbreviationName).toBe('icpcPrelim2023'); + }); + + test('returns titleFontSize as text-md', () => { + expect(provider2023.getMetadata().titleFontSize).toBe('text-md'); + }); + }); + + describe('getDisplayConfig', () => { + test('returns isShownHeader as false', () => { + expect(provider2023.getDisplayConfig().isShownHeader).toBe(false); + }); + + test('returns isShownRoundLabel as false', () => { + expect(provider2023.getDisplayConfig().isShownRoundLabel).toBe(false); + }); + + test('returns isShownTaskIndex as true', () => { + expect(provider2023.getDisplayConfig().isShownTaskIndex).toBe(true); + }); + + test('returns empty roundLabelWidth', () => { + expect(provider2023.getDisplayConfig().roundLabelWidth).toBe(''); + }); + + test('returns correct tableBodyCellsWidth', () => { + expect(provider2023.getDisplayConfig().tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-2', + ); + }); + }); + + describe('getContestRoundLabel', () => { + test('returns label with year', () => { + expect(provider2023.getContestRoundLabel('ICPCPrelim2023')).toBe('ICPC 国内予選 2023'); + }); + }); + + describe('getContestRoundIds', () => { + test('returns contest_id of the filtered tasks', () => { + expect(provider2023.getContestRoundIds(tasks2023)).toEqual(['ICPCPrelim2023']); + }); + + test('returns empty array for empty input', () => { + expect(provider2023.getContestRoundIds([] as TaskResults)).toEqual([]); + }); + }); + + describe('getHeaderIdsForTask', () => { + describe('successful cases', () => { + test('returns indices sorted numerically ascending regardless of input order', () => { + const reversedTasks = [...tasks2023].reverse() as TaskResults; + + expect(provider2023.getHeaderIdsForTask(reversedTasks)).toEqual([ + '1664', + '1665', + '1666', + '1667', + '1668', + '1669', + '1670', + '1671', + ]); + }); + + test('deduplicates repeated task_table_index values', () => { + const duplicateTasks = [tasks2023[0], tasks2023[0]] as TaskResults; + + expect(provider2023.getHeaderIdsForTask(duplicateTasks)).toEqual(['1664']); + }); + }); + + describe('edge cases', () => { + test('returns empty array for empty input', () => { + expect(provider2023.getHeaderIdsForTask([] as TaskResults)).toEqual([]); + }); + }); + }); + + describe('year boundary behavior', () => { + const provider1998 = createProvider(1998); + const provider2025 = createProvider(2025); + + test('oldest year 1998 returns correct metadata (4 problems, A–D)', () => { + expect(provider1998.getMetadata().title).toBe('ICPC 国内予選 1998'); + expect(provider1998.getMetadata().abbreviationName).toBe('icpcPrelim1998'); + }); + + test('oldest year 1998 assigns letters A–D', () => { + const table = provider1998.generateTable(tasks1998); + + expect(table['ICPCPrelim1998']['1100'].title).toBe('A. Area of Polygons'); + expect(table['ICPCPrelim1998']['1101'].title).toBe('B. A Simple Offline Text Editor'); + expect(table['ICPCPrelim1998']['1102'].title).toBe('C. Calculation of Expressions'); + expect(table['ICPCPrelim1998']['1103'].title).toBe( + 'D. Board Arrangements for Concentration Games', + ); + }); + + test('oldest year 1998 filter isolates its own contest_id', () => { + const mixed = [...tasks1998, ...tasks2023] as TaskResults; + const filtered = provider1998.filter(mixed); + + expect(filtered).toHaveLength(4); + expect(filtered.every((task) => task.contest_id === 'ICPCPrelim1998')).toBe(true); + }); + + test('latest year 2025 returns correct metadata (9 problems, A–I)', () => { + expect(provider2025.getMetadata().title).toBe('ICPC 国内予選 2025'); + expect(provider2025.getMetadata().abbreviationName).toBe('icpcPrelim2025'); + }); + + test('latest year 2025 assigns letters A–I (maximum problem count)', () => { + const table = provider2025.generateTable(tasks2025); + + expect(table['ICPCPrelim2025']['1681'].title).toBe('A. 2025'); + expect(table['ICPCPrelim2025']['1689'].title).toBe('I. Preparing the Lunch'); + }); + + test('latest year 2025 filter isolates its own contest_id', () => { + const mixed = [...tasks2025, ...tasks2023] as TaskResults; + const filtered = provider2025.filter(mixed); + + expect(filtered).toHaveLength(9); + expect(filtered.every((task) => task.contest_id === 'ICPCPrelim2025')).toBe(true); + }); + }); + + describe('override map path', () => { + const TEST_YEAR = 9999; + const TEST_CONTEST_ID = `ICPCPrelim${TEST_YEAR}`; + + const overrideTasks: TaskResults = [ + { contest_id: TEST_CONTEST_ID, task_id: '9001', task_table_index: '9001', title: 'Task One' }, + { contest_id: TEST_CONTEST_ID, task_id: '9002', task_table_index: '9002', title: 'Task Two' }, + ] as TaskResults; + + beforeEach(() => { + ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID] = { + '9001': 'X', + '9002': 'Y', + }; + }); + + afterEach(() => { + delete ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID]; + }); + + test('uses override map when ICPC_PRELIM_LABEL_OVERRIDES has an entry for the contest', () => { + const provider = createProvider(TEST_YEAR); + const table = provider.generateTable(overrideTasks); + + expect(table[TEST_CONTEST_ID]['9001'].title).toBe('X. Task One'); + expect(table[TEST_CONTEST_ID]['9002'].title).toBe('Y. Task Two'); + }); + }); +}); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts new file mode 100644 index 000000000..46f3ab0b9 --- /dev/null +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts @@ -0,0 +1,72 @@ +import { ContestType } from '$lib/types/contest'; +import type { TaskResult, TaskResults } from '$lib/types/task'; +import { + type ContestTableMetaData, + type ContestTableDisplayConfig, + type ContestTable, +} from '$features/tasks/types/contest-table/contest_table_provider'; + +import { ContestTableProviderBase } from './contest_table_provider_base'; +import { buildAojIcpcLetterMap } from './aoj_icpc_labels'; + +export class AojIcpcPrelimProvider extends ContestTableProviderBase { + private readonly year: number; + private readonly contestId: string; + + constructor(contestType: ContestType, year: number) { + super(contestType, String(year)); // provider key: AOJ_ICPC::2025 + this.year = year; + this.contestId = `ICPCPrelim${year}`; + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => taskResult.contest_id === this.contestId; + } + + // Override: prepend assigned letter to the title (display only; DB unchanged). + // ICPC titles are stored as "{name}" only, so no prefix removal is needed. + generateTable(filtered: TaskResults): ContestTable { + const letterMap = buildAojIcpcLetterMap( + this.contestId, + filtered.map((taskResult) => taskResult.task_table_index), + ); + const table: ContestTable = { [this.contestId]: {} }; + + for (const taskResult of filtered) { + const index = taskResult.task_table_index; + const letter = letterMap.get(index) ?? index; + table[this.contestId][index] = { ...taskResult, title: `${letter}. ${taskResult.title}` }; + } + + return table; + } + + // Ensure left-to-right cell order is numeric (A,B,C...). Safeguard for variable-width ids. + getHeaderIdsForTask(filtered: TaskResults): string[] { + return Array.from(new Set(filtered.map((taskResult) => taskResult.task_table_index))).sort( + (first, second) => Number(first) - Number(second), + ); + } + + getMetadata(): ContestTableMetaData { + return { + title: `ICPC 国内予選 ${this.year}`, + abbreviationName: `icpcPrelim${this.year}`, + titleFontSize: 'text-md', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return `ICPC 国内予選 ${this.year}`; + } +} diff --git a/src/features/tasks/utils/contest-table/contest_table_provider.ts b/src/features/tasks/utils/contest-table/contest_table_provider.ts index 0fa0e7988..4c4f64f46 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider.ts @@ -14,3 +14,5 @@ export * from './dp_providers'; export * from './fps24_provider'; export * from './acl_providers'; export * from './joi_providers'; +export * from './aoj_icpc_providers'; +export * from './aoj_icpc_labels'; diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts index 135080c95..d8d0f1d55 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts @@ -31,10 +31,13 @@ import { TessokuBookForPracticalsProvider, TessokuBookForChallengesProvider, MathAndAlgorithmProvider, + AojIcpcPrelimProvider, prepareContestProviderPresets, } from './contest_table_provider'; import { TESSOKU_SECTIONS } from '$features/tasks/types/contest-table/contest_table_provider'; +import { ICPC_PRELIM_OLDEST_YEAR, ICPC_PRELIM_LATEST_YEAR } from './contest_table_provider_groups'; + describe('prepareContestProviderPresets', () => { test('expects to create ABS preset correctly', () => { const group = prepareContestProviderPresets().ABS(); @@ -287,6 +290,18 @@ describe('prepareContestProviderPresets', () => { ); }); + test('expects to create AojIcpcPrelim preset correctly', () => { + const group = prepareContestProviderPresets().AojIcpcPrelim(); + + expect(group.getGroupName()).toBe('ICPC 国内予選'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'ICPC 国内予選', + ariaLabel: 'Filter ICPC Domestic Preliminary', + }); + expect(group.getSize()).toBe(ICPC_PRELIM_LATEST_YEAR - ICPC_PRELIM_OLDEST_YEAR + 1); // 28 + expect(group.getProvider(ContestType.AOJ_ICPC, '2023')).toBeInstanceOf(AojIcpcPrelimProvider); + }); + test('expects to verify all presets are functions', () => { const presets = prepareContestProviderPresets(); @@ -309,6 +324,7 @@ describe('prepareContestProviderPresets', () => { expect(typeof presets.Acl).toBe('function'); expect(typeof presets.JOIFirstQualRound).toBe('function'); expect(typeof presets.JOISecondQualAndSemiFinalRound).toBe('function'); + expect(typeof presets.AojIcpcPrelim).toBe('function'); }); test('expects each preset to create independent instances', () => { diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts index dd2cbb162..5bde6224d 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts @@ -32,8 +32,12 @@ import { JOIQualRoundFrom2006To2019Provider, JOISemiFinalRoundProvider, } from './joi_providers'; +import { AojIcpcPrelimProvider } from './aoj_icpc_providers'; import { ContestTableProviderGroup } from './contest_table_provider_group'; +export const ICPC_PRELIM_OLDEST_YEAR = 1998; +export const ICPC_PRELIM_LATEST_YEAR = 2025; + /** * Prepare predefined provider groups * Easily create groups with commonly used combinations @@ -224,6 +228,19 @@ export const prepareContestProviderPresets = () => { new JOIQualRoundFrom2006To2019Provider(ContestType.JOI), new JOISemiFinalRoundProvider(ContestType.JOI), ), + + AojIcpcPrelim: () => { + const group = new ContestTableProviderGroup('ICPC 国内予選', { + buttonLabel: 'ICPC 国内予選', + ariaLabel: 'Filter ICPC Domestic Preliminary', + }); + // Iterate from latest to oldest so the newest year's table renders on top. + for (let year = ICPC_PRELIM_LATEST_YEAR; year >= ICPC_PRELIM_OLDEST_YEAR; year--) { + group.addProvider(new AojIcpcPrelimProvider(ContestType.AOJ_ICPC, year)); + } + + return group; + }, }; }; @@ -249,6 +266,7 @@ export const contestTableProviderGroups = { acl: presets.Acl(), joiFirstQualRound: presets.JOIFirstQualRound(), joiSecondQualAndSemiFinalRound: presets.JOISecondQualAndSemiFinalRound(), + aojIcpcPrelim: presets.AojIcpcPrelim(), }; export type ContestTableProviderGroups = keyof typeof contestTableProviderGroups; From afa14b18417de0dd73147ee95c8897d7f75dad7d Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 10 Jun 2026 11:42:47 +0000 Subject: [PATCH 2/7] docs(types): update titleFontSize TSDoc example to text-md Co-Authored-By: Claude Sonnet 4.6 --- .../tasks/types/contest-table/contest_table_provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/tasks/types/contest-table/contest_table_provider.ts b/src/features/tasks/types/contest-table/contest_table_provider.ts index cd174e64a..c3d22fae2 100644 --- a/src/features/tasks/types/contest-table/contest_table_provider.ts +++ b/src/features/tasks/types/contest-table/contest_table_provider.ts @@ -127,7 +127,7 @@ export type ContestTable = Record>; * @typedef {Object} ContestTableMetaData * @property {string} title - The title text to display for the contest table. * @property {string} abbreviationName - Contest abbreviation, used for map keys. - * @property {string} [titleFontSize] - Tailwind class for title font size, e.g. 'text-xl'. Defaults to 'text-2xl' at render. + * @property {string} [titleFontSize] - Tailwind class for title font size, e.g. 'text-md'. Defaults to 'text-2xl' at render. */ export type ContestTableMetaData = { title: string; From d9105dd8422c1a50f9498ebcb63aba766087e97a Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 10 Jun 2026 12:16:22 +0000 Subject: [PATCH 3/7] feat(contest): add ContestTableTitleStyle type and group mainTitle heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single titleFontSize field with a ContestTableTitleStyle struct (headingTag, fontSize, fontWeight, bottomGap) to allow per-provider heading customisation. Add optional mainTitle to ContestTablesMetaData and render it as an h2 above the provider list in TaskTable; set it to 'ICPC 国内予選' for the ICPC Prelim group. Co-Authored-By: Claude Sonnet 4.6 --- .../components/contest-table/TaskTable.svelte | 13 ++++++++-- .../contest-table/contest_table_provider.ts | 24 +++++++++++++++++-- .../contest-table/aoj_icpc_providers.test.ts | 9 +++++-- .../utils/contest-table/aoj_icpc_providers.ts | 7 +++++- .../contest_table_provider_groups.test.ts | 1 + .../contest_table_provider_groups.ts | 1 + 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index c3048c395..620cfb33d 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -56,6 +56,7 @@ contestTableProviderGroups[activeContestType as ContestTableProviderGroups], ); let providers = $derived(providerGroups?.getAllProviders() ?? []); + let groupMetadata = $derived(providerGroups?.getMetadata()); interface ProviderData { filteredTaskResults: TaskResults; @@ -214,6 +215,13 @@ + +{#if groupMetadata?.mainTitle} + + {groupMetadata.mainTitle} + +{/if} + @@ -225,8 +233,9 @@ {metadata.title} diff --git a/src/features/tasks/types/contest-table/contest_table_provider.ts b/src/features/tasks/types/contest-table/contest_table_provider.ts index c3d22fae2..1214a0188 100644 --- a/src/features/tasks/types/contest-table/contest_table_provider.ts +++ b/src/features/tasks/types/contest-table/contest_table_provider.ts @@ -127,12 +127,30 @@ export type ContestTable = Record>; * @typedef {Object} ContestTableMetaData * @property {string} title - The title text to display for the contest table. * @property {string} abbreviationName - Contest abbreviation, used for map keys. - * @property {string} [titleFontSize] - Tailwind class for title font size, e.g. 'text-md'. Defaults to 'text-2xl' at render. + * @property {ContestTableTitleStyle} [titleStyle] - Title heading style overrides. Defaults applied at render. */ export type ContestTableMetaData = { title: string; abbreviationName: string; - titleFontSize?: string; + titleStyle?: ContestTableTitleStyle; +}; + +/** + * Visual style for a contest table's title heading. + * + * Each field is optional; the renderer applies the noted default when unset. + * + * @typedef {Object} ContestTableTitleStyle + * @property {'h2' | 'h3'} [headingTag] - Heading element tag. Defaults to 'h2' at render. + * @property {string} [fontSize] - Tailwind font-size class, e.g. 'text-md'. Defaults to 'text-2xl' at render. + * @property {string} [fontWeight] - Tailwind font-weight class, e.g. 'font-normal'. Defaults to Flowbite Heading default (bold). + * @property {string} [bottomGap] - Tailwind padding-bottom class, e.g. 'pb-1'. Defaults to 'pb-3' at render. + */ +export type ContestTableTitleStyle = { + headingTag?: 'h2' | 'h3'; + fontSize?: string; + fontWeight?: string; + bottomGap?: string; }; /** @@ -141,10 +159,12 @@ export type ContestTableMetaData = { * @typeof {Object} ContestTablesMetaData * @property {string} buttonLabel - The text to display on the contest table's primary action button. * @property {string} ariaLabel - Accessibility label for screen readers describing the contest table. + * @property {string} [mainTitle] - Group-level heading rendered once above the providers (opt-in). Not rendered when unset. */ export type ContestTablesMetaData = { buttonLabel: string; ariaLabel: string; + mainTitle?: string; }; /** diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts index 78ae26930..5b3c93cc5 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts @@ -235,8 +235,13 @@ describe('AojIcpcPrelimProvider', () => { expect(provider2023.getMetadata().abbreviationName).toBe('icpcPrelim2023'); }); - test('returns titleFontSize as text-md', () => { - expect(provider2023.getMetadata().titleFontSize).toBe('text-md'); + test('returns titleStyle with text-md font size, pb-2 bottom gap, and h3 heading tag', () => { + expect(provider2023.getMetadata().titleStyle).toEqual({ + headingTag: 'h3', + fontSize: 'text-md', + fontWeight: 'font-normal', + bottomGap: 'pb-1', + }); }); }); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts index 46f3ab0b9..182c90ddf 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts @@ -52,7 +52,12 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase { return { title: `ICPC 国内予選 ${this.year}`, abbreviationName: `icpcPrelim${this.year}`, - titleFontSize: 'text-md', + titleStyle: { + headingTag: 'h3', + fontSize: 'text-md', + fontWeight: 'font-normal', + bottomGap: 'pb-1', + }, }; } diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts index d8d0f1d55..d5a08b324 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts @@ -297,6 +297,7 @@ describe('prepareContestProviderPresets', () => { expect(group.getMetadata()).toEqual({ buttonLabel: 'ICPC 国内予選', ariaLabel: 'Filter ICPC Domestic Preliminary', + mainTitle: 'ICPC 国内予選', }); expect(group.getSize()).toBe(ICPC_PRELIM_LATEST_YEAR - ICPC_PRELIM_OLDEST_YEAR + 1); // 28 expect(group.getProvider(ContestType.AOJ_ICPC, '2023')).toBeInstanceOf(AojIcpcPrelimProvider); diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts index 5bde6224d..6e39c0c46 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts @@ -233,6 +233,7 @@ export const prepareContestProviderPresets = () => { const group = new ContestTableProviderGroup('ICPC 国内予選', { buttonLabel: 'ICPC 国内予選', ariaLabel: 'Filter ICPC Domestic Preliminary', + mainTitle: 'ICPC 国内予選', }); // Iterate from latest to oldest so the newest year's table renders on top. for (let year = ICPC_PRELIM_LATEST_YEAR; year >= ICPC_PRELIM_OLDEST_YEAR; year--) { From 08d0f8746d9b189f777219e27e1c335e971b6058 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 10 Jun 2026 12:32:15 +0000 Subject: [PATCH 4/7] docs: add Pattern 4 (constructor-parameter provider) to rules, skill, and guide Document lessons from the ICPC Prelim implementation: {@const} placement restriction in Svelte templates, mutable module-level export testing pattern, and the full Pattern 4 checklist covering year-range constants, generateTable/ getHeaderIdsForTask key alignment, override map testing, titleStyle, and mainTitle. Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/svelte-components.md | 19 ++++++ .claude/rules/testing.md | 19 ++++++ .../instructions.md | 23 ++++++- .../how-to-add-contest-table-provider.md | 61 +++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md index bcb24da19..6f354556b 100644 --- a/.claude/rules/svelte-components.md +++ b/.claude/rules/svelte-components.md @@ -83,6 +83,25 @@ Import from `flowbite-svelte`. Use Tailwind v4 `dark:` prefix. When copying butt - Move business logic to `_utils/` or `utils/` with tests - Keep components thin: one responsibility +## `{@const}` Placement Restriction + +`{@const}` is only valid as the immediate child of a control-flow block (`{#if}`, `{#each}`, `{:else}`, etc.) — placing it at the template top level is a compile error. + +When you need a derived value in both a `{#if}` condition and its body, declare it with `$derived` in `