diff --git a/.agents/skills/add-contest-table-provider/instructions.md b/.agents/skills/add-contest-table-provider/instructions.md index 0d2c9f2de..5f3fcb777 100644 --- a/.agents/skills/add-contest-table-provider/instructions.md +++ b/.agents/skills/add-contest-table-provider/instructions.md @@ -81,8 +81,8 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] Export `OLDEST_YEAR` / `LATEST_YEAR` constants. Use `super(contestType, String(param))` for unique provider key - [ ] Tests: `getSize() === LATEST - OLDEST + 1`, etc. - [ ] **RED** → implement → **GREEN** -- [ ] If display label needed: use `getTaskLabels` — do NOT mutate title inside `generateTable` (optimistic update + `$derived` re-run causes prefix accumulation) -- [ ] For AOJ-specific options (`getTaskLabels` / override map / `titleStyle` / `columnWrapThreshold`), see `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` +- [ ] If cells need an index prefix (e.g. `A. title`): set `isShownTaskIndex: true` so the cell builds it from `task_table_index` — do NOT mutate title inside `generateTable` (optimistic update + `$derived` re-run causes prefix accumulation) +- [ ] For AOJ-specific options (`titleStyle` / `columnWrapThreshold`), see `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` --- diff --git a/.agents/skills/add-tasks/instructions.md b/.agents/skills/add-tasks/instructions.md index 63dfd1353..e9c8f6191 100644 --- a/.agents/skills/add-tasks/instructions.md +++ b/.agents/skills/add-tasks/instructions.md @@ -17,6 +17,7 @@ - Source: `https://kenkoooo.com/atcoder/resources/problems.json` - atcoder.jp is unreachable and WebFetch truncates problems.json, so a miss there is not evidence of absence — ask the user to paste the rows - Use `id` / `problem_index` / `name` / `title` verbatim; do not derive `id` from contest_id +- ICPC Prelim / ICPC Regional / JAG Prelim: set `problem_index` to the problem letter, not the AOJ problem id — A, B, C... in ascending problem id within the contest, as `assignProblemLetters` does on import. `seed_problem_letters.test.ts` checks this ## Tests diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md new file mode 100644 index 000000000..aa03d0d6d --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -0,0 +1,315 @@ +# 調査の根拠データと検討の経緯 + +[plan.md](./plan.md) の各主張の裏付け。単独では読まず、plan.md から参照される想定。 + +**plan.md の決定が常に優先される。** ここに残した検討過程は、後から「なぜその案を採らなかったか」を辿るための記録であり、決定そのものではない。 + +2026-10-21 に現行コードと再照合し、行番号と件数を更新した(5 章の既存項目は当時の記録のため据え置き、6・7 項目めを追記)。 + +--- + +# 1. 実測データ + +## 対象規模 + +`prisma/tasks.ts` の実データ集計。**1,099 問 / 129 コンテスト**(2026-10-21 の再集計でも内訳まで同値)。 + +| 種別 | コンテスト | 問題数 | Provider | +| ------------ | ---------- | ------ | -------- | +| ICPCPrelim | 29 | 197 | あり | +| ICPCRegional | 27 | 266 | あり | +| JAGPrelim | 23 | 169 | あり | +| JAGSummer | 36 | 338 | **なし** | +| JAGWinter | 10 | 87 | **なし** | +| JAGSpring | 4 | 42 | **なし** | + +合宿系(Summer / Winter / Spring)は **seed にデータが入っているが Provider が無い**ためコンテスト表に出ていない。移行対象からは外せない。 + +全 1,099 問で `problem_index` == `task_id`(AOJ 問題ID)。 + +## 本番の件数(2026-10-21) + +本番で `contest_type` ごとに集計した結果。seed と一致するのは `AOJ_ICPC` だけだった。 + +| contest_type | 本番(コンテスト / 問題) | seed(コンテスト / 問題) | +| ---------------- | ------------------------- | ------------------------- | +| `AOJ_ICPC` | 56 / 463 | 56 / 463 | +| `AOJ_JAG` | 89 / 794 | 73 / 636 | +| `AOJ_UNIVERSITY` | 27 / 30 | 27 / 29 | +| `AOJ_COURSES` | 8 / 247 | 0 / 0 | +| `AOJ_PCK` | 1 / 12 | 0 / 0 | + +`AOJ_ICPC` / `AOJ_JAG` を `contest_id` の接頭辞で分けると次のとおり。`AOJ_JAG` の差(16 コンテスト / 158 問)はすべて JAG 模擬地区(管理画面から取り込める。seed には 0 件)で、PR1 / PR2 の対象範囲は本番と seed で一致する。 + +| 接頭辞 | 本番(コンテスト / 問題) | seed(コンテスト / 問題) | +| -------------- | ------------------------- | ------------------------- | +| `ICPCPrelim` | 29 / 197 | 29 / 197 | +| `ICPCRegional` | 27 / 266 | 27 / 266 | +| `JAGPrelim` | 23 / 169 | 23 / 169 | +| `JAGRegional` | 16 / 158 | 0 / 0 | +| `JAGSummer` | 36 / 338 | 36 / 338 | +| `JAGWinter` | 10 / 87 | 10 / 87 | +| `JAGSpring` | 4 / 42 | 4 / 42 | + +AtCoder 側(`AOJ_*` 以外)で title が `${task_table_index}. ` で始まらない問題は、本番でも `abs` の practice_1 の 1 件だけだった。 + +## 欠落は機械的に検出できない + +AOJ 問題ID の連番性を調べると 129 コンテスト中 128 が連番。**だが欠落が無い証拠にならない。** AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのにコンテスト一覧(取り込み元)から外れている場合も同じである。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に問題 1123 として存在するが一覧に無く、取り込まれていない。2002 年の A〜D は 1119〜1122 で連番のままであり、1123 の次の 1124 は 2003 年の A である。 + +データから欠落を示唆するのは**問題数の異常**の方: + +| コンテスト | 問題数 | 同種の中央値 | +| -------------------------- | ------ | ------------ | +| `JAGSummer2006-day1` | 1 | 10 | +| `JAGWinter2006-day2` | 2 | 10 | +| `JAGWinter2006-day3` | 4 | 10 | +| `JAGSummer2007-day1` | 5 | 10 | +| `ICPCPrelim1998/2000/2002` | 各 4 | 8 | + +古い回ほど少ない。ただし**当時は本当に問題数が少なかった**可能性も同程度にあり、データだけでは判別できない。 + +## `title` の持ち方が AOJ 内で割れている + +| 範囲 | 件数 | `title` の形 | 例 | +| ------------------------------------------ | ----- | ------------------------------- | --------------------------------- | +| ICPC国内 / アジア / JAG模擬国内(**PR1**) | 632 | 素 | `'Peculiar Protocol'` | +| JAG 合宿系(**PR2**) | 467 | **数値ID のプレフィックス付き** | `'2389. Asynchronous Exceptions'` | +| AtCoder 等(対象外) | 1,366 | index のプレフィックス付き | `'EX8. たくさんのA足すB問題'` | + +632 / 467 の分かれ目が PR1 / PR2 の境界と**完全に一致**する。 + +退行の機序: + +``` +現在: index='2389', title='2389. Asynchronous Exceptions' + → replace('2389. ', '') → 'Asynchronous Exceptions' 正常 + +PR2 後: index='A', title='2389. Asynchronous Exceptions' + → replace('A. ', '') → 空振り + → '2389. Asynchronous Exceptions' が露出 退行 +``` + +`removeTaskIndexFromTitle`(`src/lib/utils/task.ts:123`)は `${index}. ` の決め打ち置換のため、 `task_table_index` だけを更新すると空振りする。 + +**プレフィックスは 467 件すべてで `task_id` と完全に一致していた(不一致 0 件)。** したがって除去も復元も機械的に安全に行える。 + +## 復元可能性の実測 + +| 対象 | 件数 | 復元 | +| ------------------------ | ----- | ---------------------------------------------------------------------------------------- | +| AOJ ICPC/JAG(更新対象) | 1,099 | **無条件に可能**。`task_table_index == task_id` が例外ゼロで成立 | +| その他(AtCoder 等) | 1,367 | **条件付き**。84.2%(1,151 件)は `task_id` のサフィックスから導出できるが、216 件は例外 | + +導出できない 216 件の内訳: `abc` 41(`abc318_h` に対し index は `Ex`)、`typical` 32、 `APG` 27(`APG4bPython_co` に対し index は `EX8`)、`arc` 27、`tdpc` 20、 `math-and-algorithm` 15、ほか(`task_id` にサフィックスが無い AOJ 大学系 29 件を含む)。 + +`abc*_h → Ex` は規則として書けるため理論上は 90% 超まで導出できるが、 **事故対応の手段としては成立しない**。理由は 2 つ。 + +1. 「84% は規則で戻せるが 16% は個別対応」という状態は、復旧手順として使えない +2. `task_table_index` を上書きした後では、**どの行が元から `A` だったのかを区別できない** + +AOJ ICPC/JAG の 1,099 件が「無条件に一括復元できる」のとは質的に異なる。 + +## 既存 DB は自動更新されない + +- seed: `prisma/seed.ts:192-199` で `findUnique(task_id)` → 既存なら **skip** +- インポート: `src/lib/services/tasks.ts:175-180` で `isExistsTask(task_id)` → 既存なら **silent return** +- `updateTask`(`src/lib/services/tasks.ts:206`)は `grade` しか触らない + +## 一意性は壊れない + +`prisma/schema.prisma` の Task で unique なのは 2 つのみ。 + +| フィールド | 由来 | +| ------------------- | ------------------------------ | +| `id` (@id @unique) | `sha256(contest_id + task.id)` | +| `task_id` (@unique) | `task.id` をそのまま | + +どちらも入力は contest_id と AOJ 問題ID のみ。`problem_index` は混ざらない。FK も全て `task_id` 参照(`TaskTag` / `TaskAnswer` / `WorkBookTask` / `VoteGrade` / `VotedGradeCounter` / `VotedGradeStatistics`)。ハッシュの `id` を FK にしている箇所は無い。 + +**性質の変化**: `task_table_index` は現状「全 DB でユニーク」だが変更後は **「コンテスト内でのみユニーク」**になる。unique 制約は無く、コンテスト横断で index をキーにしている箇所も無い(`getTaskLabels` は contestId で名前空間を切り、 `sortAojHeaderIds` は単一コンテスト内で閉じる)ため問題ない。 + +## 変更しても安全な箇所 + +- **`formatAojTitle`** … PR1 範囲は `title` に index プレフィックスが無いため二重化しない +- **`getTaskUrl(contest_id, task_id)`** … index 非依存 +- **`ContestTaskPair` のマージ** … `prisma/contest_task_pairs.ts` に ICPC/JAG のエントリは **0 件** +- **`columnWrapThreshold: 6`** … 問題数は変わらないので影響なし + +## 出典欄の呼び出し元 + +`addContestNameToTaskIndex`(`src/lib/contests/utils/task_index_label.ts:4-12`)は 1 関数で、呼び出し元 6 箇所は自動的に揃う。 + +- `src/lib/components/TaskList.svelte:124` +- `src/lib/components/TaskListSorted.svelte:55` +- `src/lib/components/TaskForm.svelte:51` +- `src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte:106` +- `src/features/workbooks/components/detail/WorkBookTasksTable.svelte:202` +- `src/routes/workbooks/[slug]/+page.svelte:52` + +`getAojContestLabel`(`src/lib/contests/utils/labels/aoj.ts:52`)と `getAojUniversityContestLabel`(同 `:63`)から全角括弧を外す場合(M11)に巻き込まれる箇所 — `getContestNameLabel` を AOJ コンテストに直接使っている: + +- `src/lib/utils/task_filter.ts:39` — 検索マッチ +- `src/lib/components/SubmissionStatus/UpdatingModal.svelte:69` — `"{label} - {title}"` (現状 `"(…) - title"` で元々不格好) +- `src/lib/components/TagForm.svelte:99` +- `src/routes/votes/+page.svelte:114` +- `src/routes/(admin)/tasks/_components/TaskTableForImport.svelte:53` + +## テストの影響範囲 + +| ファイル | 行数 | 関連 | +| ---------------------------- | ---- | --------------------------- | +| `aoj_icpc_providers.test.ts` | 902 | comparator / ヘルパー | +| `aoj_jag_providers.test.ts` | 455 | 同上 | +| `aoj_labels.test.ts` | 186 | 同上 | +| `task_index_label.test.ts` | 213 | 出典欄 | +| `labels/index.test.ts` | 184 | 出典欄(M11) | +| `labels/aoj.test.ts` | 82 | 出典欄(M11、大学系を含む) | + +期待値の多くは fixture 側にある。`src/lib/contests/fixtures/contest_name_labels.ts`(349 行)と `contest_name_and_task_index.ts`(986 行)の AOJ 部分(大学系を含む)が全角括弧と数値 index を前提にしている。 + +--- + +# 2. SQL の解説 + +## SQL の読み方 + +**大きなクエリは内側から読み、各段階で「これは何の表を作るのか」を問う。** `WITH` 形式ならその順序がそのまま上から下になる。 + +`letters` が作るのは **`task_id` と `letter` の 2 列だけの一時的な表**。 + +| task_id | letter | +| ------- | ----------------------------------------- | +| 1100 | A | +| 1101 | B | +| 1102 | C | +| 1103 | D | +| 1672 | **A** ← コンテストが変わり番号が 1 に戻る | +| 1673 | B | + +これが分かれば `UPDATE` 側は単純で、「`task` と `letters` を `task_id` で突き合わせ、一致した行に `letter` を書き込む」だけ。 + +| 句 | 意味 | +| ------------------------------ | ---------------------------------------------------------------------- | +| `PARTITION BY contest_id` | **コンテストごとにグループ分けし、番号を 1 から振り直す** | +| `ORDER BY task_id::int` | 各グループ内で問題ID の数値昇順に並べる | +| `row_number()` | 並べた順に 1, 2, 3… を返す(**1 始まり**) | +| `chr(64 + n)` | 文字コード変換。`chr(65)` = `A` なので `n=1 → A`、`n=2 → B` | +| `contest_id LIKE 'JAGPrelim%'` | `AOJ_JAG` には合宿系も含まれるため、PR1 では接頭辞で模擬国内だけに絞る | + +`64` は 1 始まりの分を引いた値。現行の `buildAojLetterMap` は 0 始まりのループ変数で `String.fromCharCode(65 + i)` としており、結果は同じになる。 + +`PARTITION BY` が無いと対象の全問題(PR1 なら 632 問)に通し番号が振られ、2 つ目以降のコンテストが `Z` を超えて壊れる。 + +> **SQL は書かれた順に評価されない。** 論理的な処理順序は `FROM`(サブクエリと別名の確定)→ `WHERE` → `SELECT` / `SET`。このため別名を定義より前に参照しているように見えても正しい(`SELECT u.name FROM users AS u` と同じ理屈)。 + +実データでの確認: `ICPCPrelim2024`(9 問)は `1672 → A`, `1673 → B`, … `1680 → I`。 `ICPCPrelim1998` は 4 問なので `A`〜`D`。 + +## 計算量 — O(N²) にはならない + +- 外側の `AND (t.contest_type = … OR …)` は **join ではなくフィルタ**。1 行あたり O(1) で評価され、行数を減らす方向にしか働かない。`AND` は「同じ 1 行に対する複数の判定」であって集合同士の組み合わせではない +- join 側も、`task_id` に UNIQUE インデックスがある(`prisma/migrations/0_init/migration.sql:134` `task_task_id_key`)ため、Nested Loop + Index Scan なら O(M log N)、Hash Join なら O(N + M)。二乗にはならない +- 支配項はウィンドウ関数のソート O(K log K)(K = PR1 で 632、PR2 で 467)。実行時間はミリ秒台 + +**推測ではなく実行計画で確認すること。** staging で `EXPLAIN ANALYZE` を付けて実行し、内側ループに `Seq Scan` が出ていないかを見る。 + +--- + +# 3. 検討の経緯(採らなかった案) + +**以下はいずれも plan.md で却下・保留と決定済み。** 判断の理由を残すための記録。 + +## `title` に記号を入れる案を採らない理由 + +`title` を `'A. Peculiar Protocol'` に揃えれば AtCoder 側 1,366 件と形式が揃うが、採らない。 + +- **同じ情報を 2 列に持つとドリフトする。** 欠落判断の誤りを修正するとき(修正は起こる前提)、両方を更新しないと不整合になる +- **G2 に逆行する。** title にプレフィックスを埋め込む設計は、表示側に `removeTaskIndexFromTitle` という文字列 replace を要求する。除去したかった種類の変換そのもの +- **`replace` は脆い。** 破壊的変更 5 の退行がその実例 + +「入れない」を選んでも AtCoder と AOJ で title の意味は揃わないが、 **どちらを選んでも不整合は残る**(AtCoder 側を触るのはスコープ外)。コードは既に `formatAojTitle`(AOJ・付与)と `removeTaskIndexFromTitle`(AtCoder・除去)で経路が分かれているため、AOJ を「素の title」に寄せる方が既存の設計と整合する。 + +## 管理画面案を保留にした理由 + +「管理画面から SvelteKit の action で更新する」案には実質的な利点がある。 + +- **再実行できる。** マイグレーションは適用後 immutable なので、欠落判断の誤りが後から見つかると新しいマイグレーションを積むことになる +- 欠落判断は人間が行う(M6)以上、**修正は起こる前提**であるべき + +一方で、この案は**問題記号の入力元が定義できないと成立しない**。手入力なら 1,099 回の手作業に戻り、難所 1 で否定したものに戻る。コード上の対応表を読むなら prefix / problem_id は入力ではなくなり、実質「対応表を DB に同期するボタン」になる。 + +さらに、素直に実装すると **N+1 になりやすい**。既存の `createTask` がまさにその形で(`src/routes/(admin)/tasks/+page.server.ts:85` の `Promise.all` + check-then-act)、トランザクションが無いため途中で失敗すると部分適用が残る。採るなら `updateMany` か `$executeRaw` で一括処理する設計が前提になる。 + +**再検討のタイミング**: PR2 で例外が多く、修正が繰り返し発生すると分かった時点。PR1 は欠落が少ない見込みなのでマイグレーション 1 本で足りる。 + +## 逆順(合宿系を先)を採らなかった理由 + +「動いているものへの改修を後に回す方が PR のレビュー負荷が低い」という利点はある。実際、合宿系を先にすれば既存コードへの変更は `mapToTask` の分岐追加だけで済む。 + +ただし出典欄の反映が PR2 まで持ち越しになり、対応表のフォーマットを最も判断の重い 50 コンテストで作ることになる。**仕様反映の速さを優先して順序を決めた。** + +--- + +# 4. Task テーブルへの書き込み経路の全数調査 + +リポジトリ全体(`src` / `prisma` / `e2e`)を確認した結果。移行 UPDATE の安全性の根拠。 + +## 書き込み経路は 3 つだけ、削除経路はゼロ + +| 経路 | 実装 | 書き込むフィールド | +| -------------------- | ------------------------------- | ------------------------------------------------------------------------------- | +| 管理画面インポート | `src/lib/services/tasks.ts:182` | `id` / `task_id` / `contest_id` / `contest_type` / `task_table_index` / `title` | +| 管理画面の難易度更新 | `src/lib/services/tasks.ts:208` | `grade` のみ | +| seed | `prisma/seed.ts:217` | 上記 + `grade`(`id` は未指定) | + +- `updateTask` の呼び出し元は 2 つ(`src/routes/(admin)/tasks/[task_id]/+page.server.ts:73`、 `src/routes/(admin)/tasks/grade/+page.server.ts:34`)。いずれも `grade` のみ +- `task.delete` / `deleteMany` / `upsert` / `createMany` は**リポジトリ全体に存在しない** +- 生 SQL(`$executeRaw` / `$queryRaw`)による Task への書き込みもなし +- 他モデルからのネストした `task: { create: ... }` もなし + +⇒ **一意フィールドを UPDATE するコードは構造的に存在しない。** + +## `id` の採番規則がインポートと seed で不一致 + +- インポート: `sha256(contest_id + task.id)` — 決定的 +- seed: `id` 未指定のため fabbrica の自動採番(`prisma/.fabbrica/index.ts:915`)で連番文字列 + +一意性は破れないが「`Task.id` はハッシュ」という前提は**開発環境では成立しない**。ただし **`Task.id` を再計算して引く箇所はゼロ**で、全ルックアップは `task_id` 経由。現状の実害はないが、将来 `Task.id` に決定性を期待するコードを書くと静かに壊れる。 + +## check-then-act の競合 + +`createTask` は `isExistsTask`(`findMany`)→ `create` の check-then-act で、 `src/routes/(admin)/tasks/+page.server.ts:85` がこれを `Promise.all` で並列実行している。 + +同一 `task_id` が 1 回のインポート JSON に 2 件含まれると、両方がチェックを通過して 2 件目が P2002 で落ちる。`Promise.all` 全体が reject して 500 を返すが、 **トランザクションが無いため先に成功した分はロールバックされない**(部分適用)。 + +`prisma/seed.ts:192-199` も同じ形で PQueue concurrency 3 で並列実行しており同種のレースがある。 + +一意性は DB 制約が守るので壊れはしないが、エラー時の挙動が中途半端。 `docs/guides/agent-rules/prisma-db.md` の「Idempotent Writes」に該当し、本来は `createMany({ skipDuplicates: true })` か `upsert` が解。**本件とは独立の既存課題**(O5)。 + +## 他の sha256 決定的 ID は Task から独立 + +`src/lib/services/answers.ts:86,105` と `src/features/votes/services/vote_grade.ts:107,120,146` はいずれも入力が `Task.id` ではなく `task_id`。`vote_grade.ts` は全て `upsert` で `where` に複合ユニークを使うため id 衝突の経路もない。 + +## 削除経路がないことの帰結 + +Task を削除するコードが存在しないため、関連テーブルが孤児 FK を持つ事故は現状起こらない。将来 Task 削除機能を追加する場合、6 つの関連のうち `TaskTag` / `TaskAnswer` / `WorkBookTask` は `onDelete` 未指定なので要注意(投票系 3 つは `Cascade`)。 + +--- + +# 5. 調査中の誤りと訂正(自戒) + +1. **対象を 632 問と集計した** — Provider が存在する 3 種のみを数え、seed にデータがある合宿系 467 問を落としていた。正しくは 1,099 問。 _教訓: 「実装がある範囲」と「データがある範囲」は一致しない。集計条件は後者で取る。_ + +2. **「問題ID が連番だから欠落なし、機械生成できる」と結論した** — 欠落問題は ID も採番されないため、連番性は欠落の有無について何も語らない。検証が問いに答えていなかった。 _教訓: 「観測できる範囲に異常が無い」ことを「異常が無い」と読み替えない。欠落は定義上、観測できない側にある。_ + +3. **抽出スクリプトのバグを、データの欠落として提示しかけた** — `prisma/tasks.ts` からタスクを抜き出す正規表現がシングルクォート前提だったため、 `name: "A Bug That's Not a Pill Bug"`(アポストロフィを含むためダブルクォート)を取りこぼし、`ICPCPrelim2024` が 8 問に見えた。実際は 9 問で、欠番は存在しなかった。 _教訓: 抽出スクリプトを書いたら、まず総数が既知の値と一致するかを確認する。1 と同じ「観測範囲を実際の範囲と取り違える」失敗であり、本件では欠落の誤検出に直結する。_ + +4. **移行 SQL を seed 由来の対応表から生成する案を出した** — production のタスクは管理画面インポート経由で入っており seed と一致する保証がないため、production にしか無い行が取り残される。しかも `UPDATE` は該当行が無くてもエラーにならない。 _教訓: 2 と同じ型。「seed で見えている範囲」を「production の実態」と取り違えた。_ + +5. **AtCoder 系の復元を「不可能」と断定した** — 実際は `task_id` のサフィックスから 83.4% が導出できる。ただし事故対応としては成立しないという結論自体は変わらない。 _教訓: 断定する前に測る。結論が同じでも、根拠が誤っていると次の判断で誤用される。_ + +6. **PR を分けた後も、移行 SQL の範囲を 1,099 件のままにしていた**(2026-10-21 に発見)— `contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` は合宿系も含むため、PR1 で 467 件を余計に更新するところだった。 _教訓: 範囲を分割したら、範囲を表す条件をすべて追従させる。分類(`contest_type`)と分割の単位が一致しているとは限らない。_ + +7. **欠落した問題を「AOJ に存在しない(ID も無い)」と決めつけていた**(2026-10-21 に発見)— ICPC 国内予選 2002 の E は AOJ に問題 1123 として存在し、コンテスト一覧(取り込み元)から外れているだけだった。PR3 でダミーを作るという前提は、この問題には当てはまらない。 _教訓: 「一覧に無い」と「存在しない」は別。データの取得元の範囲を、対象が実在する範囲と取り違えない(1・4 と同じ型)。_ + +なお本調査は当初「上流が `title` / `problem_index` を変更した場合」という別の問いから始まったため、初版には鉄則本の章分類(`tessoku_book_providers.ts`)や `ContestTaskPair` の `replace` 誤置換に関する記述があった。いずれも本件では対象外のため削除済み。 diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md new file mode 100644 index 000000000..681ee870e --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -0,0 +1,441 @@ +# AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する + +**日付**: 2026-09-09(2026-10-21 に現行コードと再照合し、参照・数値を更新)/ **状態**: PR0 完了(#4212)・PR1 実装済み(2026-10-24、[実施記録](#pr1-の実施記録2026-10-24)) **実測データと検討の経緯は** [investigation.md](./investigation.md) + +## ゴール + +`problem_index`(DB 上は `task_table_index`)に AOJ 問題ID ではなく**問題記号**を持たせ、 **表示側が変換なしにそれを使える状態にする。** + +> **問題記号**: `A`, `B`, `C`… のように、そのコンテスト内で何番目の問題かを表す記号。AtCoder では `abc300_a` の `a` にあたる。AOJ には存在しない(難所 1)。コード上は `letter`。 + +| | 現在 | 目標 | +| -------------------------------------- | ------------------------------------------- | -------------------------------------------- | +| **G1** 出典欄(`TaskList.svelte:124`) | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | +| **G2** コンテストテーブル | 問題ID 3467 → ソート位置から `F` を**算出** | `task_table_index` の `F` を**そのまま使う** | + +G2 は「アドホックな変換をなくす」ことが本体。位置ベース導出(`buildAojLetterMap`)が残る限りゴール未達になる。 + +### 対象と PR 分割 + +**全体で 1,099 問 / 129 コンテスト。** + +| | 範囲 | コンテスト | 問題数 | Provider | 到達点 | +| ------- | ----------------------------------------- | ---------- | ------ | -------- | -------------------------------------------- | +| **PR0** | 出典欄の書式(AOJ 全体) | — | — | — | G1 の書式を先に入れる(index は数値のまま) | +| **PR1** | ICPC 国内予選 / アジア地区 / JAG 模擬国内 | 79 | 632 | 実装済み | G1 / G2 をこの範囲で達成 | +| **PR2** | JAG 夏合宿 / 冬合宿 / 春合宿 | 50 | 467 | 未実装 | 残りに拡大 + Provider 追加 | +| **PR3** | 欠落問題の補完 | — | — | — | AOJ のコンテスト一覧に無い問題をどう埋めるか | + +PR1 / PR2 は「AOJ のコンテスト一覧にある問題の記号を正しくする」で完結し、PR3 に進まなくても機能として成立する。 + +### スコープ外(残タスク) + +PR0 の書式変更は出典欄の AOJ 分岐全体に効くが、index を記号にするのは ICPC / JAG 模擬国内 / JAG 合宿系だけである。次の範囲は数値の index のまま残し、別タスクとする。件数は本番の集計(2026-10-21)。 + +| 範囲 | 本番の件数(コンテスト / 問題) | PR0 後の出典欄 | 残す理由 | +| ------------ | ------------------------------- | ----------------------------------------------------- | --------------------------------------------------------- | +| AOJ Courses | 8 / 247 | `プログラミング入門 - ITP1_1_A(AOJ)` のように不格好 | 問題ID が非数値で、基本ルールの `task_id::int` が使えない | +| AOJ PCK | 1 / 12 | 数値の問題ID のまま | 優先度が低い | +| AOJ 大学系 | 27 / 30 | `UTPC 2010 - 2207(AOJ)` | 優先度が低い(2279 だけは記号で登録済み) | +| JAG 模擬地区 | 16 / 158 | 数値の問題ID のまま | 本番にしか無く(seed は 0 件)、Provider も無い | + +## 3 つの難所 + +**難所 1: 問題記号は AOJ API から取得できない。** `mapToTask`(`src/lib/clients/aizu_online_judge/utils.ts:65`)のコメントが明言しているとおり、API は「その問題がコンテストで何番目か」を返さない。現在の数値は問題ID の代用。 + +ただし**基本ルール**(コンテスト内で問題ID が若い順に `A`, `B`, `C`…)で復元できる。これは現行 `buildAojLetterMap` の導出と同一なので、**生成した対応表は現在の表示を再現する**。PR1 の回帰確認はここに依拠できる。⇒ 対応表はスクリプトで機械生成できる。 + +**難所 2: 欠落があると基本ルールが破れる。しかも欠落は検出できない。** ここでの欠落は、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いことを指す。AOJ に登録されていない問題なら **ID も無い**ため、残った問題の ID は連番のままになる。AOJ に問題はあるのに一覧から外れている場合もある(ICPC 国内予選 2002 の E = AOJ 1123)。どちらの場合も、コンテスト内の連番性は欠落の有無を何も語らない。⇒ **コンテスト単位で外部確認するしかない**(唯一の手作業)。 + +この構造こそが変更の動機でもある。導出のままでは欠落を表現できず例外表を抱えるしかないが、データ化すれば欠落コンテストも通常のデータとして扱える。 + +**難所 3: 表示層で逃げられない。** 出典欄は `task_table_index` を**直接**読み、コンテスト表の問題記号マップはこの経路に関与しない。⇒ DB の値そのものを直す以外に手段がない。 + +## 破壊的変更 5 点 + +| # | 変更 | 起きること | 対処 | +| --- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| 1 | `task_table_index` の値が変わる | **既存レコードは自動更新されない**(seed も import も「既存なら skip」) | 移行 UPDATE(M2) | +| 2 | index が非数値になる | `aoj_labels.ts:26,54` の `Number(a) - Number(b)` が NaN になりソートが無効化。列順が `task_id` 降順に化ける | `localeCompare` へ(M4)。**移行 UPDATE の後** | +| 3 | `getTaskLabels` を削除すると | `taskLabel` が undefined になり `"A. "` プレフィックスが消える | セル側を index 直参照に(M10) | +| 4 | コンテスト名の全角括弧を外すと | `getAojContestLabel` と `getAojUniversityContestLabel` が `'(…)'` を埋め込むため、**AOJ ラベルを使う他 5 箇所の表示も変わる** | 許容して廃止(M11) | +| 5 | **合宿系のみ `title` に数値プレフィックスがある** | `'2389. …'` に対し `removeTaskIndexFromTitle` が空振りし、**問題ID がタイトルに露出する** | PR2 で `title` も正規化(M9) | + +- 変更 1 で**一意性は壊れない**。`id` / `task_id` は `problem_index` を入力にしておらず、FK も全て `task_id` 参照([根拠](./investigation.md#一意性は壊れない)) +- 変更 5 は **PR1 の 632 件には影響しない**(title が素なので空振りして無変化)。PR1 は安全、PR2 だけが危険という非対称がある([実測](./investigation.md#title-の持ち方が-aoj-内で割れている)) + +## 決定事項 + +| # | 決定 | 理由 | +| --- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| M1 | DB の値そのものを A/B/C に直す | 難所 3 | +| M2 | 移行 UPDATE を実施する | 破壊的変更 1 | +| M3 | JAG 合宿系 467 問も対象に含める(PR2 に分割) | seed にデータがあり Provider だけが無い | +| M4 | `sortAojHeaderIds` の comparator を `localeCompare` へ | 破壊的変更 2 | +| M5 | 管理画面からの取り込み時に、ICPC / JAG 模擬国内は基本ルールで記号を付ける | 放置すると次回の取り込みで数値 index が再混入する。毎年の新しいコンテストも追記なしで記号になる | +| M6 | 対応表は基本ルールで生成し、欠落確認は人間が行う | 難所 2 | +| M7 | `buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` を削除 | **G2 そのもの** | +| M8 | 移行は Prisma のデータマイグレーションで適用 | CI の既存の仕組みに乗る | +| M9 | PR2 で合宿系の `title` から数値プレフィックスを除去 | 破壊的変更 5 | +| M10 | `getTaskLabels` を削除し、セル側を `task_table_index` 直参照へ | 恒等マップを組む無駄を残さない。G2 の趣旨 | +| M11 | `getAojContestLabel` / `getAojUniversityContestLabel` から全角括弧を外す | 出典欄の目標書式ではコンテスト名を括弧で囲まない(括弧は末尾の `(AOJ)` だけで、コードベースの慣習どおり全角)。AOJ 大学系も出典欄の AOJ 分岐(`isAojContest`)を通るため両方が対象。**他 5 箇所への波及を許容する** | + +OPTIONAL は 1 件のみ。**O5**: `createTask` の check-then-act を upsert 化 —本件と独立の既存課題で別ブランチ推奨([詳細](./investigation.md#check-then-act-の競合))。 + +## 却下した方針 + +| 案 | 却下理由 | +| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 対応表を `prisma/tasks.ts` に手書きし seed のみ直す | `mapToTask` が数値を入れ続け、取り込みのたびに**新旧書式が混在**する | +| DB は数値のまま `AOJ_LABEL_OVERRIDES` を拡充し表示層で解決 | 移行不要で最も安いが、**ゴールを満たせない**(難所 3) | +| 問題ID の連番性から欠落を機械判定 | **論理的に成立しない**(難所 2) | +| seed を冪等化して production に流す | **不可**。`addUsers` / `addAtCoderAccounts` / `addWorkBooks` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts:71-81`) | +| `title` に記号を入れて `'A. Peculiar Protocol'` に揃える | 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) | +| 管理画面に同期 action を足す | **保留**。再実行できる利点はあるが問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) | +| 632 件の対応表を `src/lib/clients/aizu_online_judge/` に置き、`mapToTask` で引く(旧・案 B) | 毎年の新しいコンテスト(ICPC 国内予選 / アジア地区、JAG 模擬国内)で表への追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる(2026-10-21 に変更) | + +採用は**取り込み時の基本ルール**: `AojChallengesApiClient.getTasks` で、ICPC(国内予選 / アジア地区)と JAG 模擬国内のときだけ、コンテストの問題を ID 昇順に並べて A, B, C… を付ける([1-1 の契約](#1-1-の契約))。seed は生成スクリプトで書き換える。途中に欠番がある新しいコンテストでは記号がずれるが、気づけないのは現状の表示と同じである。見つかった時点で補正表を足す(PR1 の範囲では補正の必要が無いため、補正表は作らない)。 + +> **この条件分岐はスコープの区切りであって、恒久的な設計ではない。** AOJ 由来のコンテストは最終的に A/B/C 形式へ揃えるのが自然だが、 **優先度は低く後回し**。取り込み時の基本ルールと移行 SQL は流用でき、対象の条件を広げるだけで済む。 +> +> - **AOJ 大学**(RUPC / HUPC / UAPC / OUPC / UTPC / KUPC)… 29 件 / 27 コンテスト。28 件は `index == task_id` の数値で**同じ問題を抱えている**。本方式をそのまま適用できる。ただし `AOJ-KUPC2011` の 2279 は変更後の形式(`problem_index: 'I'`)で登録済み(本番も同じ)。AOJ に 1 問しか無いため基本ルールでは `A` になってしまう。範囲を広げるときは移行 SQL の対象から外す。title だけは `'I. Mountain'` と記号付きなので、PR1 で素の `'Mountain'` に揃える +> - **AOJ PCK / Courses** … ContestType は定義済みだがデータ未投入。ただし Courses は task_id が `ITP1_1_A` 形式で**非数値**のため、基本ルールの `task_id::int` が使えず別の導出が要る +> +> 移行 SQL の `contest_type` / `contest_id` 条件は、スコープ限定であると同時に **Courses の非数値 ID で `task_id::int` が失敗するのを防いでいる**。 + +## 移行手段 + +CI が `github.ref` でジョブを分け、staging と production の両方に `prisma migrate deploy` を実行している(`.github/workflows/ci.yml:91,130`)。 + +| ブランチ | 適用先 | +| ----------- | --------------------------------------------- | +| `main` 以外 | `PREVIEW_DATABASE_URL`(`ci.yml:115-119`) | +| `main` | `PRODUCTION_DATABASE_URL`(`ci.yml:150-154`) | + +いずれも build / lint / check / unit-test ジョブの成功後、`vercel build` の**後**・Vercel デプロイの**前**に走る(build が失敗すれば DB に触れない)。`main` へ昇格した時点で production に適用されるため、**staging での確認が自然にゲートになる**。 + +> **要確認**: CI は `pull_request` でも起動し、preview ジョブの条件は `github.ref != 'refs/heads/main'` だけである。YAML 上は、**同一リポジトリのブランチから PR を開いた時点で** `PREVIEW_DATABASE_URL` に適用される(staging へのマージ前)。Actions の実行履歴で確かめること。このため SQL の確認は PR を開く前にローカルで済ませる([手順と確認](#手順と確認))。 + +### SQL — 対応表を焼き込まず、基本ルールを SQL で表現する + +**production のタスクは管理画面インポート経由で入っており seed と一致する保証がない。** seed 由来の一覧から `VALUES` を生成すると production にしか無い行が取り残され、 `UPDATE` は該当行が無くてもエラーにならないため**取りこぼしが静かに起きる**。 + +PR1 の SQL(ICPC 国内予選 / アジア地区 / JAG 模擬国内の 632 件): + +```sql +-- (1) Build a task_id -> problem letter lookup table. +WITH letters AS ( + SELECT task_id, + chr(64 + row_number() OVER ( + PARTITION BY contest_id -- Restart numbering for each contest. + ORDER BY task_id::int -- AOJ problem ids are numeric. + )::int) AS letter + FROM "task" + WHERE contest_type = 'AOJ_ICPC' + -- AOJ_JAG also covers training camps, so narrow it by contest_id. + OR (contest_type = 'AOJ_JAG' AND contest_id LIKE 'JAGPrelim%') +) +-- (2) Apply the lookup table to the task rows. +UPDATE "task" AS t +SET "task_table_index" = letters.letter +FROM letters +WHERE t."task_id" = letters.task_id + -- Redundant with the CTE filter, but keeps the blast radius fixed. + AND ( + t."contest_type" = 'AOJ_ICPC' + OR (t."contest_type" = 'AOJ_JAG' AND t."contest_id" LIKE 'JAGPrelim%') + ); +``` + +`row_number()` は `bigint` を返し、`chr(bigint)` は存在しない(`ERROR: function chr(bigint) does not exist`)。このため `::int` が要る(2026-10-24 にローカルで確認。当初の SQL には無かった)。 + +**範囲は `contest_type` だけでは絞れない。** 合宿系(`JAGSummer` / `JAGWinter` / `JAGSpring`)も模擬国内と同じ `AOJ_JAG` に分類される(`src/lib/contests/utils/classification.ts:76` の `regexForJag`)。`contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` のままだと PR1 で合宿系 467 件まで更新される。そうなると破壊的変更 5 が PR1 で起き、欠落確認前の記号が合宿系に入る。このため `contest_id` の接頭辞で絞る。 + +PR2 の SQL は同じ形で、CTE と外側の `WHERE` の両方の条件を合宿系に差し替える。PR1 の範囲を含めたまま流すと、欠落対応で手補正した記号が基本ルールの値に戻るため、PR2 でも範囲を絞る。 + +```sql +-- PR2 scope: JAG training camps only. +contest_type = 'AOJ_JAG' AND contest_id ~ '^JAG(Summer|Winter|Spring)' +``` + +DB の実データに対して実行されるので seed との差異が問題にならない。 `task_id::int` は安全(129 コンテストすべてで問題ID が数値であることを確認済み)、1 コンテスト最大 14 問なので `chr(64 + n)` は A〜Z に収まる(`row_number()` は 1 始まりなので `chr(65)` = `A` から)。 + +読み方と計算量の分析は [investigation.md](./investigation.md#sql-の読み方)。 + +**PR1 のみ**: 2279 の `title` を素の形に揃える。index は本番も `I` で正しいため触らない。現在値を条件にしているので、既に揃っている環境では 0 件で終わる。 + +```sql +-- Align the AOJ-KUPC2011 title with the plain AOJ title format. +UPDATE "task" +SET "title" = 'Mountain' +WHERE "task_id" = '2279' + AND "title" = 'I. Mountain'; +``` + +**PR2 のみ**: `title` の正規化(M9)。`contest_type = 'AOJ_JAG'` には模擬国内も含まれるが、 `~ '^[0-9]+\. '` の条件で対象は合宿系のみになる。 + +```sql +-- Strip the legacy numeric-id prefix from AOJ training camp titles. +UPDATE "task" +SET "title" = regexp_replace("title", '^[0-9]+\. ', '') +WHERE "contest_type" = 'AOJ_JAG' + AND "title" ~ '^[0-9]+\. '; +``` + +### 手順と確認 + +1. `pnpm exec prisma migrate dev --create-only --name update_aoj_task_table_index` +2. SQL + 例外の上書きを `migration.sql` に書く +3. **ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する** +4. ローカルで `pnpm exec prisma migrate dev` を実行して適用し、表示を確認する +5. PR を開く → staging へマージして確認 → `main` へ昇格 + +手順 3–4 は**必ず PR を開く前に**済ませる。PR を開いた時点で preview DB に適用されうるうえ([移行手段](#移行手段)の要確認)、適用済みのマイグレーションは編集しても再適用されない。PR を開いた後に SQL の誤りが見つかったら、新しいマイグレーションを積む。ローカル DB は seed 由来なので、確かめられるのは SQL のロジックまでである。 + +手順 3 により **SQL の正しさを構文から判断する必要がなくなる**ので、必ず通すこと。全件を見る必要はないが、**コンテストの切り替わり目(記号が `A` に戻る箇所)**と **問題数の少ない古いコンテスト**は目視する価値がある。 + +ローカル(手順 3–4)と staging での確認項目: + +- **更新件数**が想定(PR1 = 632 と 2279 の title 1 件、PR2 = 467)と一致するか。PR1 で 1,099 件になったら合宿系を除外できていない +- `EXPLAIN ANALYZE` に想定外の `Seq Scan` が無いか +- コンテストテーブルの表示が**変わらない**こと(基本ルールは現行の導出と同一のため) + +**staging が防げないもの**: **対応表・欠落判断そのものの誤り**。誤った記号でも SQL は正常に完走し、staging では「それらしい表示」になる(難所 2)。 + +### 事故対策 + +| 観点 | 内容 | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **爆発半径** | 復元できるのは AOJ ICPC/JAG の 1,099 件のみ。WHERE 句が壊れて AtCoder 系に波及すると**一括復元の手段が無くなる**([実測](./investigation.md#復元可能性の実測))。外側 `WHERE` の重複条件はこのため | +| **復元** | 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる | +| **バックアップ** | Supabase Pro のため PITR が利用可。復元トリックが効くのは一度目だけなので、**再修正時は PITR が唯一の手段** | +| **`updated_at`** | `@updatedAt` は Prisma Client 側の実装で DB トリガーではない。生 SQL では更新されない。**意図的に含めない** | +| **キャッシュ** | TTL 1 時間(`src/lib/clients/cache.ts:157`)。生 SQL は `invalidateTaskCaches()` を経由しない。ローカル / staging で「更新したのに表示が変わらない」ときはこれを疑う | +| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される。2026-10-24 にローカルの別スキーマで、3 文目が失敗するマイグレーションを `prisma migrate deploy` し、1–2 文目(CREATE / INSERT)も残らないことを確認した | +| **デプロイ直前の数分** | マイグレーションの適用から Vercel のデプロイ完了までの間は、旧コードが記号のデータを `Number()` で並べるため、記号がずれて表示されうる。数分で解消するので**許容する** | + +## フェーズ + +**PR0(出典欄の書式)→ PR1(ICPC / JAG 模擬国内)→ PR2(合宿系)→ PR3(欠落補完)** の順。PR0 は DB に依存しないので先に出し、ラベル周りのテストを PR1 から切り離す。PR1 を合宿系より先にする理由: 出典欄はコンテストごとに正しくなるため PR1 完了時点で成果が見える/PR1 は欠落が少なく対応表フォーマットを楽なケースで確立できる/合宿系は Provider が無いため comparator 差し替えの影響を受けず **PR1 内で完結する**。逆順を採らなかった理由は [investigation.md](./investigation.md#逆順合宿系を先を採らなかった理由)。 + +### PR0 — 出典欄の書式変更(DB 変更なし) + +| # | 内容 | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0-1 | `addContestNameToTaskIndex`(`src/lib/contests/utils/task_index_label.ts`)の AOJ 分岐を `${contestName} - ${taskTableIndex}(AOJ)` にする(G1) | +| 0-2 | `getAojContestLabel` / `getAojUniversityContestLabel`(`src/lib/contests/utils/labels/aoj.ts`)から全角括弧を外す(M11) | +| 0-3 | テスト: `task_index_label.test.ts`、`labels/aoj.test.ts`、`labels/index.test.ts` と、fixture の `contest_name_labels.ts` / `contest_name_and_task_index.ts` の AOJ 部分の期待値を書き換える | + +PR0 時点の出典欄は `ICPC 国内予選 2024 - 3467(AOJ)`(index は数値のまま)で、PR1 の移行で `ICPC 国内予選 2024 - F(AOJ)` になる。合宿系・大学系・Courses・PCK も同じ書式になる([スコープ外](#スコープ外残タスク))。変更後の形式で登録済みの `AOJ-KUPC2011` は `KUPC 2011 - I(AOJ)` になる。 + +### PR1 — ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 コンテスト / 632 問) + +| # | 内容 | 補足 | +| --- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| 1-0 | 対応表の生成と検証(Phase 0 / M6) | `generate_aoj_problem_letters.ts`([Phase 0](#phase-0-の内訳1-0--2-0-共通)) | +| 1-1 | 取り込み時に基本ルールで記号を付ける(M5) | [契約](#1-1-の契約) | +| 1-2 | `prisma/tasks.ts` の 632 件の `problem_index` と、2279 の title を書き換える | 1-0 の出力で置き換える | +| 1-3 | 移行 SQL(632 件 + 2279 の title 1 件)(M2 / M8) | | +| 1-4 | `sortAojHeaderIds` の comparator を `localeCompare` へ(M4) | 順序の制約は無い(下記) | +| 1-5 | `buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` / `formatAojTitle` を削除(M7) | G2 の本体。**1-6 と同じコミットで、1-6 の後に消す**(下記) | +| 1-6 | `getTaskLabels` を削除し、セルは `task_table_index` から表示する(M10) | [契約](#1-6-の契約) | +| 1-7 | テスト | [一覧](#1-7-のテスト) | +| 1-8 | 手順書の更新 | 下記 | +| 1-9 | 生成スクリプトを削除する | 追加とは別のコミットで消し、git の履歴から辿れるようにする | + +1-4 に順序の制約は無い。同じ PR の中なら CI がデプロイより先にマイグレーションを流すうえ、PR1 範囲の問題ID はすべて 4 桁なので、数値のままでも `localeCompare` で正しく並ぶ。 + +1-5 は単独では消せない。`buildAojLetterMap`(と、その中だけで使う `AOJ_LABEL_OVERRIDES`)の呼び出し元は AOJ の `getTaskLabels`(`aoj_icpc_providers.ts:51,95`、`aoj_jag_providers.ts:60`)だけで、`formatAojTitle` の呼び出し元は `TaskTableBodyCell.svelte:37` だけである。どれも 1-6 で消えるので、1-6 → 1-5 の順に同じコミットで行う。先に 1-5 だけ消すとビルドが壊れる。`aoj_labels.ts` の残り(`AOJ_TITLE_STYLE` / `sortAojHeaderIds` / `buildAojDisplayConfig`)は Provider が使い続けるので消さない。 + +1-8 で直す手順書: + +- `.agents/skills/add-contest-table-provider/instructions.md:85` と `docs/guides/how-to-add-contest-table-provider.md:83` から、削除する `getTaskLabels` / `AOJ_LABEL_OVERRIDES` への言及を外す +- `.agents/skills/add-tasks/instructions.md` に「ICPC / JAG 模擬国内の `problem_index` は問題記号にする」を足す。seed に手で足すと数値の index が混ざるため + +PR1 は動作中の 3 Provider を改修するため、**regression の確認が主なレビュー観点**になる。 + +**PR1 はクロスレビューが必須**。データ移行を含み、共有インターフェイス `ContestTableProvider` から `getTaskLabels` を消すため、AGENTS.md の「DB schema, migrations, or data transformations」と「Shared architecture or public interface changes」の両方に当たる。PR を出す前に Codex でクロスレビューを行う(使えなければ `coderabbit review --plain`)。 + +#### 1-1 の契約 + +```typescript +// src/lib/clients/aizu_online_judge/utils.ts +// Assign A, B, C... to problem ids in ascending numeric order (the basic rule). +export function assignProblemLetters(problemIds: string[]): Map; + +// problemIndex defaults to the AOJ problem id, as before. +export function mapToTask( + problem: AOJTaskAPI, + contestId: string, + problemIndex: string = problem.id, +); +``` + +`AojChallengesApiClient.getTasks`(`src/lib/clients/aizu_online_judge/clients.ts:91-99`)は、`contestType === 'ICPC'` または `contestType === 'JAG' && round === 'PRELIM'` のときだけ記号を付ける。取り込み時の `contest_id` は `contest.abbr` でコンテスト単位なので、日ごとではなく `contest.days.flatMap((day) => day.problems)` にまとめて `assignProblemLetters` を当てる。JAG 模擬地区と PCK は数値のまま([スコープ外](#スコープ外残タスク))。合宿系は管理画面から取り込めない(`JagRound` は `PRELIM` / `REGIONAL` のみ)。 + +`contest.abbr` が seed の `contest_id` と一致することは、AOJ API へのアクセスが許可されず直接は確かめていない。ただし本番の ICPC / JAG 模擬国内は管理画面からの取り込み(`contest.abbr` をそのまま `contest_id` にする)で入っており、本番と seed の件数は一致する。 + +#### 1-6 の契約 + +`getTaskLabels` は全 Provider が実装する共有インターフェイスに定義されているが、ICPC 国内予選の記号表示のために追加されたもの(`15f498f8`、2026-06-11)で、DB の index が数値であることが存在理由である。32 の Provider のうち上書きしているのは AOJ ICPC / JAG の 3 つだけで、残りは基底クラスの `{}` を受け継ぐだけなので、消しても動作は変わらない。呼び出し元は `TaskTable.svelte` だけで、`TaskTableBodyCell` を使うのも `TaskTable.svelte` だけである。合宿系・大学系・Courses・PCK・JAG 模擬地区には Provider が無い。AOJ の上書きには `override` が付いているので、消し忘れは型検査で検出される。 + +後続の PR に分けて、インターフェイスの `getTaskLabels` だけを残す案は採らない。上書きする Provider が 0 になり、使われないコードが残るため。 + +| 対象 | 変更 | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `ContestTableProvider`(`contest_table_provider.ts:80`)、基底クラス(`contest_table_provider_base.ts:115`)、AOJ の上書き(`aoj_icpc_providers.ts:50,94`、`aoj_jag_providers.ts:59`) | `getTaskLabels` を削除 | +| `TaskTable.svelte`(`:71,105,301,318`) | `taskLabels` と `taskLabel` の受け渡しを削除 | +| `TaskTableBodyCell.svelte` | props から `taskLabel?: string` を削除し、表示は下記 | + +```typescript +// src/lib/utils/task.ts +export const buildTitleWithTaskIndex = (title: string, taskTableIndex: string): string => + `${taskTableIndex}. ${removeTaskIndexFromTitle(title, taskTableIndex)}`; + +// TaskTableBodyCell.svelte +let displayTitle = $derived( + isShownTaskIndex + ? buildTitleWithTaskIndex(taskResult.title, taskResult.task_table_index) + : removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index), +); +``` + +AtCoder 側の表示は変わらない。`isShownTaskIndex: true` の Provider(典型 90 問 / ACL / 鉄則 / FPS 24 題 / アルゴリズムと数学 / 組合せゲーム 24 題 / AALC / DP / AWC)の title は `${task_table_index}. ` で始まるので、外してから付け直すと元に戻る。seed で title にプレフィックスの無い問題は `abs` の practice_1 だけで、ABS は `isShownTaskIndex: false` である。本番でも、プレフィックスの無い title はこの 1 件だけだった(2026-10-21 に集計)。 + +#### 1-7 のテスト + +| 区分 | 対象 | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 削除 | `aoj_labels.test.ts` の `buildAojLetterMap` と `formatAojTitle`、`contest_table_provider_base.test.ts:27-37`(`getTaskLabels` の既定値)、`aoj_icpc_providers.test.ts` / `aoj_jag_providers.test.ts` の `getTaskLabels` のケース、`aoj_icpc_providers.test.ts` の `override map path`(3 か所。`AOJ_LABEL_OVERRIDES` を使う。うち 1 つは `generateTable` のテスト) | +| 書き換え | `aoj_icpc_providers.test.ts` / `aoj_jag_providers.test.ts` / `aoj_labels.test.ts` の fixture の `task_table_index` を数値から記号へ(`sortAojHeaderIds` の期待値を含む) | +| 追加 | `buildTitleWithTaskIndex` の単体テスト(`src/lib/utils/task.test.ts` を新設)、`assignProblemLetters` と `getTasks` の記号付与(`utils.test.ts` / `clients.test.ts`)、`prisma/tasks.ts` の ICPC / JAG 模擬国内の `problem_index` が `assignProblemLetters` の結果と一致する整合テスト(`src/lib/clients/aizu_online_judge/` に置く。src のテストから `prisma/tasks.ts` を読む前例は無い) | +| 手動 | コンテスト表には E2E テストもコンポーネントテストも無い。PR1 の前後でコンテスト表のスクリーンショットを比べ、79 コンテストの表示が変わらないことを確かめる。2026-10-21 に撮ったスクリーンショットを基準にする | + +書き換えたテストと追加したテストには `verify-test-strength` skill を当てる。 + +#### PR1 の範囲の本番データ + +本番の ICPC 国内予選 / アジア地区 / JAG 模擬国内は 79 コンテスト / 632 問で、seed と一致する(2026-10-21 に集計、[内訳](./investigation.md#本番の件数2026-10-21))。本番の `AOJ_JAG` が seed より多い分(16 コンテスト / 158 問)は JAG 模擬地区で、PR1 / PR2 の移行 SQL の条件から外れる。 + +#### PR1 の実施記録(2026-10-24) + +移行 SQL は `prisma/migrations/20261024033928_update_aoj_task_table_index/migration.sql`。 + +適用前の確認(ローカル DB、手順 3): + +- `migration.sql` の CTE と外側の `WHERE` をそのまま使い、`UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を出した。632 行 / 79 コンテスト、`task_id` の重複なし、全行で現在値 = `task_id`(数値) +- 生成スクリプトの JSON(632 件)と突き合わせ: 片側にしか無い ID 0、記号の不一致 0 +- 現行の表示ロジック(`buildAojLetterMap`)を現在値に当てた記号とも突き合わせ: 不一致 0。各コンテストの記号は A から連続 +- 2279 の title 更新対象は 1 行 +- `EXPLAIN`: `task` の Seq Scan 2 回(CTE と外側。`contest_type` に索引が無いため想定どおり)+ Hash Join + +適用後の確認(手順 4): + +- 632 行がプレビューの記号と一致。範囲外の AOJ(合宿系・模擬地区 518 行、PCK、Courses、大学系)は数値の index のまま。2279 は `I` / `Mountain` +- 旧コード(`Number` ソート + `buildAojLetterMap` + `formatAojTitle`)で適用前のデータを描画した結果と、新コード(`localeCompare` + `buildTitleWithTaskIndex`)で適用後のデータを描画した結果が、79 コンテストすべてで列順・セルの文字列とも一致した。スクリーンショットによる目視確認(1-7 の手動)は未実施 +- `removeTaskIndexFromTitle` は当初 `"{index}. "` の最初の出現を位置を問わず消していた。title の途中に `"{記号}. "` を含む問題は seed にも DB にも無い(0 件)が、レビューを受けて先頭のプレフィックスだけを消すよう直した(下記) + +計画からの差分: + +- AOJ API の JAG 模擬国内には 2 日開催のコンテスト(fixture の `JAGPrelim20162`、2738–2751)がある。取り込み時の `contest_id` は日ではなくコンテスト単位なので A–N になる(日ごとに振ると同じ `contest_id` に A–G が重複する)。seed / 本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存 ID を skip するため影響しない + +テストの検出力(`verify-test-strength`): 次の 15 個の変異をすべて検出した。`assignProblemLetters` の数値→辞書順ソート / 降順 / 記号の開始位置 / 入力の破壊、`mapToTask` が `problemIndex` を無視、`getTasks` の JAG 条件の反転 / 全種別に記号 / 1 日目だけで記号付与、seed の 1 件を数値に戻す / A・B の入れ替え、`sortAojHeaderIds` の降順 / `Number` 比較 / 重複除去なし、`buildTitleWithTaskIndex` の除去なし / title をそのまま返す。単独で検出する変異が無いテストは seed 整合テストの `ICPC Prelim 2024 F is problem 1677` だけだが、このファイルで唯一の literal な期待値(対応表とデータが一緒にずれた場合の歯止め)なので残した。 + +クロスレビュー(Codex、2026-10-24): 移行 SQL に不具合の指摘は無かった。指摘 3 件はいずれも PR1 では対応せず、残タスクとする。 + +| 指摘 | 扱い | +| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Moderate: API の `JAGPrelim20162`(2 回開催)を取り込むと、2745 が seed の `A` ではなく `H` になる | **残タスク**。本番には影響しない: 本番の `contest_id` は `JAGPrelim2016A` / `B` が各 7 問に分かれており(2026-10-24 に本番で確認)、移行 SQL は `contest_id` ごとに採番するので各 A–G になる。取り込みは既存の `task_id` を skip する。直すなら、取り込み時に 2 回開催を seed と同じ `A` / `B` の `contest_id` に分け、それぞれ A から振る | +| Minor: seed 整合テストの期待値を本番関数 `assignProblemLetters` で作っており、testing.md の「期待値は literal」に合わない | **残タスク**。記号自体の正しさは生成スクリプトの照合で確認済みで、このテストは取り込みとの整合を見るもの。literal な fixture(632 件)を足すかは別途判断する | +| 潜在リスク: `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する | **残タスク**。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める | + +CodeRabbit のレビュー(2026-10-24): + +| 指摘 | 扱い | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `clients.ts`: 2016 年の 2 回開催を `JAGPrelim2016A` / `B` に分けて取り込み、それぞれ A から振る | **見送り**。Codex の Moderate と同じ指摘で、上表のとおり残タスク。直すなら `getContests` 側も同じ `contest_id` に揃える必要がある | +| `task.ts`: `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す | **対応**。index が空のときに title 途中の `. ` まで消えていた(`Dr. Podboq` → `DrPodboq`)点も直る。seed と DB(ローカル 2,832 件)に途中だけに現れる title は無く、既存データの表示は変わらない | +| `buildTitleWithTaskIndex` のテストを `task.ts` に隣接するファイルへ移す | **対応**。`src/lib/utils/task.test.ts` を新設(`task_grade` と同じく、新しいテストは隣接、既存は `src/test/` に残す)。`removeTaskIndexFromTitle` の先頭限定のテストもここに置いた | + +### PR2 — JAG 夏 / 冬 / 春合宿(50 コンテスト / 467 問) + +| # | 内容 | 制約 | +| --- | ------------------------------------------ | ---------------------------------------------------------------------------- | +| 2-0 | **対応表を作る**(50 コンテスト分) | 欠落の判断が要るのは主にここ | +| 2-1 | 取り込み側の変更は不要 | 合宿系は管理画面から取り込めない(`JagRound` は `PRELIM` / `REGIONAL` のみ) | +| 2-2 | `prisma/tasks.ts` の該当 467 件を書き換え | | +| 2-3 | 移行 UPDATE(467 件) | | +| 2-4 | **`title` の数値プレフィックス除去**(M9) | 省くと問題ID がタイトルに露出する | +| 2-5 | **合宿系の Provider 追加** | PR2 の本体。`add-contest-table-provider` skill が使える | +| 2-6 | テスト追加 | 新規 Provider 分 | + +PR2 完了時点で G1 / G2 が全 129 コンテストに行き渡る。 + +### PR3 — 欠落問題の補完 + +**実施する。ただし着手前に調査フェーズを置く。** + +欠落には 2 種類あり、扱いが異なる。 + +- **AOJ に問題はあるが、コンテスト一覧に無い**(例: ICPC 国内予選 2002 の E = AOJ 1123。AOJ の Sources > ICPC - Prelim では 2002 年の問題として表示される): ダミーは不要で、実在の ID で通常の問題として登録できる。ただし管理画面の取り込みは一覧を使うので取り込めない。seed への追加と、本番には移行 SQL の INSERT が要る +- **AOJ に問題そのものが無い**: ID が無いので、下記のダミー Task レコードの設計が要る + +| # | 内容 | +| --- | ------------------------------------------------------------------------------------------------------------------------- | +| 3-0 | **要調査**。PR1 / PR2 で判明した欠落について、AOJ に問題番号で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | +| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と移行 SQL) | +| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | + +3-2 で決めること: + +- **`task_id` の採番**。AOJ に無い問題には ID が存在しないため、衝突しない採番規則が要る +- **提出リンクの扱い**。リンク先が存在しないので無効化するか、表示自体を変えるか +- **投票 / タグ / 問題集への登場可否**。FK が `task_id` 参照であるため、ダミーを入れると各機能に波及する + +**PR1 / PR2 のデータ移行とは性質が異なる**(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。 + +### Phase 0 の内訳(1-0 / 2-0 共通) + +1. **基本ルールで対象範囲のコンテストをスクリプト生成** — 現行の導出と同じ結果になるので、この時点でコンテストテーブルの表示は現状と一致する +2. **コンテスト単位で欠落の有無を外部情報から確認** ← 人手が必要なのはここだけ。外部情報は**メンテナが管理している問題一覧のスプレッドシート** +3. 欠落が途中にあるコンテストだけ、記号を手で補正する(末尾の欠落は記号がずれないので補正不要) +4. 欠落した問題が AOJ に問題番号で存在するか(一覧から外れているだけか)を確かめ、PR3 に引き継ぐ + +**1 で大半が埋まる。** 残るのは 2 の確認作業で、PR1 が 79 コンテスト、PR2 が 50 コンテスト。欠落が集中するのは PR2 の合宿系。 + +> **注意**: 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても表示は成立してしまう(前詰めでズレた記号が出るだけ)。 **PR1 でも確認工程 2 は省略しないこと。** + +**生成スクリプトには総数のアサーションを入れる。** 抽出漏れが欠落の誤検出に直結する(調査中に実際に踏んだ。[事例](./investigation.md#5-調査中の誤りと訂正自戒))。抽出件数が対象範囲の既知の値(PR1 は **79 コンテスト / 632 問**、PR2 は 50 / 467)と一致しなければ**エラーで停止**する。欠落として扱わない。 + +PR1 は `generate_aoj_problem_letters.ts` で行う(1-9 で削除済み。`git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts` で参照できる)。`prisma/tasks.ts` を直接 import するので、正規表現による抜き出しで取りこぼすことはない。 + +照合の内容: + +- スプレッドシートのタイトルを記号順に書き写し(79 コンテスト / 633 問。AOJ のコンテスト一覧に無い 1 問を含む)、基本ルールで付けた記号の位置ごとに AOJ のタイトルと照合する。問題数・途中の欠番・**コンテスト内の順番の入れ替わり**まで検出できる +- 大文字小文字・空白・記号の違いは無視する。スプレッドシートのセル幅で末尾が切れていた 4 件は、推測で補わずに `…` を付け、見えている部分の前方一致で照合する +- 1 件でも合わなければ、対応表を出力せずに終了コード 1 で止まる +- わざと誤りを入れたコピー 5 種類(順番の入れ替え、途中のタイトルの誤り、途中の欠番、問題数の不足、`…` 付きタイトルの前半の誤り)で、すべて検出されることを確かめた + +確認できないこと: スプレッドシート自体の誤りと、書き写しの際にスプレッドシートと AOJ の両方と同じ誤りをした場合。書き写しは 2026-10-21 のスクリーンショットからの一度きりの手作業である。 + +実行方法と期待する出力(終了コード 0): + +```bash +pnpm exec tsx docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts +# stderr: +# Contests: 79, tasks: 632 +# Absent from prisma/tasks.ts: ICPCPrelim2002 E +# All 632 titles match the official letters +# stdout: JSON (problem id -> letter, 632 entries) +``` + +stdout の JSON は 1-2 で `prisma/tasks.ts` を書き換えるときに使う。リポジトリには残さない(スクリプトを実行すれば同じものが得られる)。 + +PR1 の結果(2026-10-21): + +- 632 問すべてのタイトルが記号の位置で一致し、補正が要るコンテストは無い +- ICPC 国内予選 2002 の E(Enclosing Circles)は、AOJ に問題 1123 として存在するがコンテスト一覧に無く、`prisma/tasks.ts` にも本番にも無い。末尾の欠番なので A〜D の記号はずれない。PR3 で通常の問題として登録する対象(ID は A〜D の 1119〜1122 と 2003 年の A の 1124 の間) + +### テストの注意 + +テスト約 2,000 行と出典欄の fixture が、数値 index と全角括弧を前提にしている。全角括弧の分は PR0(0-3)、数値 index の分は PR1([1-7](#1-7-のテスト))で直す。作業は機械的だが、期待値を新形式へ一括置換すると **テストが仕様を検証しなくなる**。`docs/guides/agent-rules/testing.md` の Test integrity と `verify-test-strength` skill の適用対象。内訳は [investigation.md](./investigation.md#テストの影響範囲)。 diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index db29b19db..518b14db2 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -80,7 +80,7 @@ protected setFilterCondition(): (taskResult: TaskResult) => boolean { - 年度範囲定数(`OLDEST_YEAR` / `LATEST_YEAR`)を `export` してテストでも参照 - グループ登録は最新年→古い年の降順(`addProvider` 順 = 表示順、先 = 上) -AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回開催の `suffix` 分割など)は `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` を参照。 +AOJ/JAG 固有の詳細(`titleStyle`、同一年2回開催の `suffix` 分割など)は `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` を参照。 ### 補足: 固定セクションによる同一 ContestType 内の共存 diff --git a/prisma/migrations/20261024033928_update_aoj_task_table_index/migration.sql b/prisma/migrations/20261024033928_update_aoj_task_table_index/migration.sql new file mode 100644 index 000000000..46c8d6f04 --- /dev/null +++ b/prisma/migrations/20261024033928_update_aoj_task_table_index/migration.sql @@ -0,0 +1,33 @@ +-- Replace the AOJ problem id in task_table_index with the problem letter (A, B, C...) +-- for ICPC Prelim / ICPC Regional / JAG Prelim. +-- Basic rule: letters follow ascending problem id within each contest. +-- JAG training camps (also AOJ_JAG) are out of scope and excluded by contest_id. + +-- (1) Build a task_id -> problem letter lookup table. +WITH letters AS ( + SELECT task_id, + chr(64 + row_number() OVER ( + PARTITION BY contest_id -- Restart numbering for each contest. + ORDER BY task_id::int -- AOJ problem ids are numeric. + )::int) AS letter + FROM "task" + WHERE contest_type = 'AOJ_ICPC' + -- AOJ_JAG also covers training camps, so narrow it by contest_id. + OR (contest_type = 'AOJ_JAG' AND contest_id LIKE 'JAGPrelim%') +) +-- (2) Apply the lookup table to the task rows. +UPDATE "task" AS t +SET "task_table_index" = letters.letter +FROM letters +WHERE t."task_id" = letters.task_id + -- Redundant with the CTE filter, but keeps the blast radius fixed. + AND ( + t."contest_type" = 'AOJ_ICPC' + OR (t."contest_type" = 'AOJ_JAG' AND t."contest_id" LIKE 'JAGPrelim%') + ); + +-- Align the AOJ-KUPC2011 title with the plain AOJ title format. +UPDATE "task" +SET "title" = 'Mountain' +WHERE "task_id" = '2279' + AND "title" = 'I. Mountain'; diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 7eb93f6d8..647ea6676 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -9974,4424 +9974,4424 @@ export const tasks = [ { id: '1100', contest_id: 'ICPCPrelim1998', - problem_index: '1100', + problem_index: 'A', name: 'Area of Polygons', title: 'Area of Polygons', }, { id: '1101', contest_id: 'ICPCPrelim1998', - problem_index: '1101', + problem_index: 'B', name: 'A Simple Offline Text Editor', title: 'A Simple Offline Text Editor', }, { id: '1102', contest_id: 'ICPCPrelim1998', - problem_index: '1102', + problem_index: 'C', name: 'Calculation of Expressions', title: 'Calculation of Expressions', }, { id: '1103', contest_id: 'ICPCPrelim1998', - problem_index: '1103', + problem_index: 'D', name: 'Board Arrangements for Concentration Games', title: 'Board Arrangements for Concentration Games', }, { id: '1104', contest_id: 'ICPCPrelim1999', - problem_index: '1104', + problem_index: 'A', name: "Where's Your Robot?", title: "Where's Your Robot?", }, { id: '1105', contest_id: 'ICPCPrelim1999', - problem_index: '1105', + problem_index: 'B', name: 'Unable Count', title: 'Unable Count', }, { id: '1106', contest_id: 'ICPCPrelim1999', - problem_index: '1106', + problem_index: 'C', name: 'Factorization of Quadratic Formula', title: 'Factorization of Quadratic Formula', }, { id: '1107', contest_id: 'ICPCPrelim1999', - problem_index: '1107', + problem_index: 'D', name: 'Spiral Footrace', title: 'Spiral Footrace', }, { id: '1108', contest_id: 'ICPCPrelim1999', - problem_index: '1108', + problem_index: 'E', name: 'A Long Ride on a Railway', title: 'A Long Ride on a Railway', }, { id: '1109', contest_id: 'ICPCPrelim2000', - problem_index: '1109', + problem_index: 'A', name: "Fermat's Last Theorem", title: "Fermat's Last Theorem", }, { id: '1110', contest_id: 'ICPCPrelim2000', - problem_index: '1110', + problem_index: 'B', name: 'Patience', title: 'Patience', }, { id: '1111', contest_id: 'ICPCPrelim2000', - problem_index: '1111', + problem_index: 'C', name: 'Cyber Guardian', title: 'Cyber Guardian', }, { id: '1112', contest_id: 'ICPCPrelim2000', - problem_index: '1112', + problem_index: 'D', name: 'Strange Key', title: 'Strange Key', }, { id: '1114', contest_id: 'ICPCPrelim2001', - problem_index: '1114', + problem_index: 'A', name: 'Get a Rectangular Field', title: 'Get a Rectangular Field', }, { id: '1115', contest_id: 'ICPCPrelim2001', - problem_index: '1115', + problem_index: 'B', name: 'Multi-column List', title: 'Multi-column List', }, { id: '1116', contest_id: 'ICPCPrelim2001', - problem_index: '1116', + problem_index: 'C', name: 'Jigsaw Puzzles for Computers', title: 'Jigsaw Puzzles for Computers', }, { id: '1117', contest_id: 'ICPCPrelim2001', - problem_index: '1117', + problem_index: 'D', name: 'Missing Numbers', title: 'Missing Numbers', }, { id: '1118', contest_id: 'ICPCPrelim2001', - problem_index: '1118', + problem_index: 'E', name: 'Nets of Dice', title: 'Nets of Dice', }, { id: '1119', contest_id: 'ICPCPrelim2002', - problem_index: '1119', + problem_index: 'A', name: 'Exploring Caves', title: 'Exploring Caves', }, { id: '1120', contest_id: 'ICPCPrelim2002', - problem_index: '1120', + problem_index: 'B', name: 'Pile Up!', title: 'Pile Up!', }, { id: '1121', contest_id: 'ICPCPrelim2002', - problem_index: '1121', + problem_index: 'C', name: 'Kanglish:Analysis on Artificial Language', title: 'Kanglish:Analysis on Artificial Language', }, { id: '1122', contest_id: 'ICPCPrelim2002', - problem_index: '1122', + problem_index: 'D', name: 'What is the Number in my Mind ?', title: 'What is the Number in my Mind ?', }, { id: '1124', contest_id: 'ICPCPrelim2003', - problem_index: '1124', + problem_index: 'A', name: 'When Can We Meet?', title: 'When Can We Meet?', }, { id: '1125', contest_id: 'ICPCPrelim2003', - problem_index: '1125', + problem_index: 'B', name: 'Get Many Persimmon Trees', title: 'Get Many Persimmon Trees', }, { id: '1126', contest_id: 'ICPCPrelim2003', - problem_index: '1126', + problem_index: 'C', name: 'The Secret Number', title: 'The Secret Number', }, { id: '1127', contest_id: 'ICPCPrelim2003', - problem_index: '1127', + problem_index: 'D', name: 'Building a Space Station', title: 'Building a Space Station', }, { id: '1128', contest_id: 'ICPCPrelim2003', - problem_index: '1128', + problem_index: 'E', name: 'Square Carpets', title: 'Square Carpets', }, { id: '1129', contest_id: 'ICPCPrelim2004', - problem_index: '1129', + problem_index: 'A', name: 'Hanafuda Shuffle', title: 'Hanafuda Shuffle', }, { id: '1130', contest_id: 'ICPCPrelim2004', - problem_index: '1130', + problem_index: 'B', name: 'Red and Black', title: 'Red and Black', }, { id: '1131', contest_id: 'ICPCPrelim2004', - problem_index: '1131', + problem_index: 'C', name: 'Unit Fraction Partition', title: 'Unit Fraction Partition', }, { id: '1132', contest_id: 'ICPCPrelim2004', - problem_index: '1132', + problem_index: 'D', name: 'Circle and Points', title: 'Circle and Points', }, { id: '1133', contest_id: 'ICPCPrelim2004', - problem_index: '1133', + problem_index: 'E', name: 'Water Tank', title: 'Water Tank', }, { id: '1134', contest_id: 'ICPCPrelim2004', - problem_index: '1134', + problem_index: 'F', name: 'Name the Crossing', title: 'Name the Crossing', }, { id: '1135', contest_id: 'ICPCPrelim2005', - problem_index: '1135', + problem_index: 'A', name: "Ohgas' Fortune", title: "Ohgas' Fortune", }, { id: '1136', contest_id: 'ICPCPrelim2005', - problem_index: '1136', + problem_index: 'B', name: 'Polygonal Line Search', title: 'Polygonal Line Search', }, { id: '1137', contest_id: 'ICPCPrelim2005', - problem_index: '1137', + problem_index: 'C', name: 'Numeral System', title: 'Numeral System', }, { id: '1138', contest_id: 'ICPCPrelim2005', - problem_index: '1138', + problem_index: 'D', name: 'Traveling by Stagecoach', title: 'Traveling by Stagecoach', }, { id: '1139', contest_id: 'ICPCPrelim2005', - problem_index: '1139', + problem_index: 'E', name: 'Earth Observation with a Mobile Robot Team', title: 'Earth Observation with a Mobile Robot Team', }, { id: '1140', contest_id: 'ICPCPrelim2005', - problem_index: '1140', + problem_index: 'F', name: 'Cleaning Robot', title: 'Cleaning Robot', }, { id: '1141', contest_id: 'ICPCPrelim2006', - problem_index: '1141', + problem_index: 'A', name: "Dirichlet's Theorem on Arithmetic Progressions", title: "Dirichlet's Theorem on Arithmetic Progressions", }, { id: '1142', contest_id: 'ICPCPrelim2006', - problem_index: '1142', + problem_index: 'B', name: 'Organize Your Train part II', title: 'Organize Your Train part II', }, { id: '1143', contest_id: 'ICPCPrelim2006', - problem_index: '1143', + problem_index: 'C', name: 'Hexerpents of Hexwamp', title: 'Hexerpents of Hexwamp', }, { id: '1144', contest_id: 'ICPCPrelim2006', - problem_index: '1144', + problem_index: 'D', name: 'Curling 2.0', title: 'Curling 2.0', }, { id: '1145', contest_id: 'ICPCPrelim2006', - problem_index: '1145', + problem_index: 'E', name: 'The Genome Database of All Space Life', title: 'The Genome Database of All Space Life', }, { id: '1146', contest_id: 'ICPCPrelim2006', - problem_index: '1146', + problem_index: 'F', name: 'Secrets in Shadows', title: 'Secrets in Shadows', }, { id: '1147', contest_id: 'ICPCPrelim2007', - problem_index: '1147', + problem_index: 'A', name: 'ICPC Score Totalizer Software', title: 'ICPC Score Totalizer Software', }, { id: '1148', contest_id: 'ICPCPrelim2007', - problem_index: '1148', + problem_index: 'B', name: 'Analyzing Login/Logout Records', title: 'Analyzing Login/Logout Records', }, { id: '1149', contest_id: 'ICPCPrelim2007', - problem_index: '1149', + problem_index: 'C', name: 'Cut the Cake', title: 'Cut the Cake', }, { id: '1150', contest_id: 'ICPCPrelim2007', - problem_index: '1150', + problem_index: 'D', name: 'Cliff Climbing', title: 'Cliff Climbing', }, { id: '1151', contest_id: 'ICPCPrelim2007', - problem_index: '1151', + problem_index: 'E', name: 'Twirl Around', title: 'Twirl Around', }, { id: '1152', contest_id: 'ICPCPrelim2007', - problem_index: '1152', + problem_index: 'F', name: 'Dr. Podboq or: How We Became Asymmetric', title: 'Dr. Podboq or: How We Became Asymmetric', }, { id: '1153', contest_id: 'ICPCPrelim2008', - problem_index: '1153', + problem_index: 'A', name: 'Equal Total Scores', title: 'Equal Total Scores', }, { id: '1154', contest_id: 'ICPCPrelim2008', - problem_index: '1154', + problem_index: 'B', name: 'Monday-Saturday Prime Factors', title: 'Monday-Saturday Prime Factors', }, { id: '1155', contest_id: 'ICPCPrelim2008', - problem_index: '1155', + problem_index: 'C', 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', + problem_index: 'D', name: 'Twirling Robot', title: 'Twirling Robot', }, { id: '1157', contest_id: 'ICPCPrelim2008', - problem_index: '1157', + problem_index: 'E', name: 'Roll-A-Big-Ball', title: 'Roll-A-Big-Ball', }, { id: '1158', contest_id: 'ICPCPrelim2008', - problem_index: '1158', + problem_index: 'F', name: 'ICPC: Intelligent Congruent Partition of Chocolate', title: 'ICPC: Intelligent Congruent Partition of Chocolate', }, { id: '1159', contest_id: 'ICPCPrelim2009', - problem_index: '1159', + problem_index: 'A', name: 'Next Mayor', title: 'Next Mayor', }, { id: '1160', contest_id: 'ICPCPrelim2009', - problem_index: '1160', + problem_index: 'B', name: 'How Many Islands?', title: 'How Many Islands?', }, { id: '1161', contest_id: 'ICPCPrelim2009', - problem_index: '1161', + problem_index: 'C', name: 'Verbal Arithmetic', title: 'Verbal Arithmetic', }, { id: '1162', contest_id: 'ICPCPrelim2009', - problem_index: '1162', + problem_index: 'D', name: 'Discrete Speed', title: 'Discrete Speed', }, { id: '1163', contest_id: 'ICPCPrelim2009', - problem_index: '1163', + problem_index: 'E', name: 'Cards', title: 'Cards', }, { id: '1164', contest_id: 'ICPCPrelim2009', - problem_index: '1164', + problem_index: 'F', name: 'Tighten Up!', title: 'Tighten Up!', }, { id: '1165', contest_id: 'ICPCPrelim2010', - problem_index: '1165', + problem_index: 'A', name: "Pablo Squarson's Headache", title: "Pablo Squarson's Headache", }, { id: '1166', contest_id: 'ICPCPrelim2010', - problem_index: '1166', + problem_index: 'B', name: 'Amazing Mazes', title: 'Amazing Mazes', }, { id: '1167', contest_id: 'ICPCPrelim2010', - problem_index: '1167', + problem_index: 'C', name: "Pollock's conjecture", title: "Pollock's conjecture", }, { id: '1168', contest_id: 'ICPCPrelim2010', - problem_index: '1168', + problem_index: 'D', name: 'Off Balance', title: 'Off Balance', }, { id: '1169', contest_id: 'ICPCPrelim2010', - problem_index: '1169', + problem_index: 'E', name: 'The Most Powerful Spell', title: 'The Most Powerful Spell', }, { id: '1170', contest_id: 'ICPCPrelim2010', - problem_index: '1170', + problem_index: 'F', name: 'Old Memories', title: 'Old Memories', }, { id: '1171', contest_id: 'ICPCPrelim2010', - problem_index: '1171', + problem_index: 'G', name: 'Laser Beam Reflections', title: 'Laser Beam Reflections', }, { id: '1172', contest_id: 'ICPCPrelim2011', - problem_index: '1172', + problem_index: 'A', name: "Chebyshev's Theorem", title: "Chebyshev's Theorem", }, { id: '1173', contest_id: 'ICPCPrelim2011', - problem_index: '1173', + problem_index: 'B', name: 'The Balance of the World', title: 'The Balance of the World', }, { id: '1174', contest_id: 'ICPCPrelim2011', - problem_index: '1174', + problem_index: 'C', name: 'Identically Colored Panels Connection', title: 'Identically Colored Panels Connection', }, { id: '1175', contest_id: 'ICPCPrelim2011', - problem_index: '1175', + problem_index: 'D', name: 'And Then. How Many Are There?', title: 'And Then. How Many Are There?', }, { id: '1176', contest_id: 'ICPCPrelim2011', - problem_index: '1176', + problem_index: 'E', name: 'Planning Rolling Blackouts', title: 'Planning Rolling Blackouts', }, { id: '1177', contest_id: 'ICPCPrelim2011', - problem_index: '1177', + problem_index: 'F', name: 'Watchdog Corporation', title: 'Watchdog Corporation', }, { id: '1178', contest_id: 'ICPCPrelim2011', - problem_index: '1178', + problem_index: 'G', name: 'A Broken Door', title: 'A Broken Door', }, { id: '1179', contest_id: 'ICPCPrelim2012', - problem_index: '1179', + problem_index: 'A', name: 'Millennium', title: 'Millennium', }, { id: '1180', contest_id: 'ICPCPrelim2012', - problem_index: '1180', + problem_index: 'B', name: 'Recurring Decimals', title: 'Recurring Decimals', }, { id: '1181', contest_id: 'ICPCPrelim2012', - problem_index: '1181', + problem_index: 'C', name: 'Biased Dice', title: 'Biased Dice', }, { id: '1182', contest_id: 'ICPCPrelim2012', - problem_index: '1182', + problem_index: 'D', name: 'Railway Connection', title: 'Railway Connection', }, { id: '1183', contest_id: 'ICPCPrelim2012', - problem_index: '1183', + problem_index: 'E', name: 'Chain-Confined Path', title: 'Chain-Confined Path', }, { id: '1184', contest_id: 'ICPCPrelim2012', - problem_index: '1184', + problem_index: 'F', name: 'Generic Poker', title: 'Generic Poker', }, { id: '1185', contest_id: 'ICPCPrelim2012', - problem_index: '1185', + problem_index: 'G', name: 'Patisserie ACM', title: 'Patisserie ACM', }, { id: '1186', contest_id: 'ICPCPrelim2013', - problem_index: '1186', + problem_index: 'A', name: 'Integral Rectangles', title: 'Integral Rectangles', }, { id: '1187', contest_id: 'ICPCPrelim2013', - problem_index: '1187', + problem_index: 'B', name: 'ICPC Ranking', title: 'ICPC Ranking', }, { id: '1188', contest_id: 'ICPCPrelim2013', - problem_index: '1188', + problem_index: 'C', name: 'Hierarchical Democracy', title: 'Hierarchical Democracy', }, { id: '1189', contest_id: 'ICPCPrelim2013', - problem_index: '1189', + problem_index: 'D', name: 'Prime Caves', title: 'Prime Caves', }, { id: '1190', contest_id: 'ICPCPrelim2013', - problem_index: '1190', + problem_index: 'E', name: 'Anchored Balloon', title: 'Anchored Balloon', }, { id: '1191', contest_id: 'ICPCPrelim2013', - problem_index: '1191', + problem_index: 'F', name: 'Rotate and Rewrite', title: 'Rotate and Rewrite', }, { id: '1192', contest_id: 'ICPCPrelim2014', - problem_index: '1192', + problem_index: 'A', name: 'Tax Rate Changed', title: 'Tax Rate Changed', }, { id: '1193', contest_id: 'ICPCPrelim2014', - problem_index: '1193', + problem_index: 'B', name: 'Chain Disappearance Puzzle', title: 'Chain Disappearance Puzzle', }, { id: '1194', contest_id: 'ICPCPrelim2014', - problem_index: '1194', + problem_index: 'C', name: 'Vampire', title: 'Vampire', }, { id: '1195', contest_id: 'ICPCPrelim2014', - problem_index: '1195', + problem_index: 'D', name: 'Encryption System', title: 'Encryption System', }, { id: '1196', contest_id: 'ICPCPrelim2014', - problem_index: '1196', + problem_index: 'E', name: 'Bridge Removal', title: 'Bridge Removal', }, { id: '1197', contest_id: 'ICPCPrelim2014', - problem_index: '1197', + problem_index: 'F', name: 'A Die Maker', title: 'A Die Maker', }, { id: '1198', contest_id: 'ICPCPrelim2014', - problem_index: '1198', + problem_index: 'G', name: "Don't Cross the Circles!", title: "Don't Cross the Circles!", }, { id: '1600', contest_id: 'ICPCPrelim2015', - problem_index: '1600', + problem_index: 'A', name: 'Entrance Examination', title: 'Entrance Examination', }, { id: '1601', contest_id: 'ICPCPrelim2015', - problem_index: '1601', + problem_index: 'B', name: 'Short Phrase', title: 'Short Phrase', }, { id: '1602', contest_id: 'ICPCPrelim2015', - problem_index: '1602', + problem_index: 'C', name: 'ICPC Calculator', title: 'ICPC Calculator', }, { id: '1603', contest_id: 'ICPCPrelim2015', - problem_index: '1603', + problem_index: 'D', name: '500-yen Saving', title: '500-yen Saving', }, { id: '1604', contest_id: 'ICPCPrelim2015', - problem_index: '1604', + problem_index: 'E', name: 'Deadlock Detection', title: 'Deadlock Detection', }, { id: '1605', contest_id: 'ICPCPrelim2015', - problem_index: '1605', + problem_index: 'F', name: 'Bridge Construction Planning', title: 'Bridge Construction Planning', }, { id: '1606', contest_id: 'ICPCPrelim2015', - problem_index: '1606', + problem_index: 'G', name: 'Complex Paper Folding', title: 'Complex Paper Folding', }, { id: '1607', contest_id: 'ICPCPrelim2015', - problem_index: '1607', + problem_index: 'H', name: 'Development of Small Flying Robots', title: 'Development of Small Flying Robots', }, { id: '1608', contest_id: 'ICPCPrelim2016', - problem_index: '1608', + problem_index: 'A', name: 'Selection of Participants of an Experiment', title: 'Selection of Participants of an Experiment', }, { id: '1609', contest_id: 'ICPCPrelim2016', - problem_index: '1609', + problem_index: 'B', name: 'Look for the Winner!', title: 'Look for the Winner!', }, { id: '1610', contest_id: 'ICPCPrelim2016', - problem_index: '1610', + problem_index: 'C', name: 'Bamboo Blossoms', title: 'Bamboo Blossoms', }, { id: '1611', contest_id: 'ICPCPrelim2016', - problem_index: '1611', + problem_index: 'D', name: 'Daruma Otoshi', title: 'Daruma Otoshi', }, { id: '1612', contest_id: 'ICPCPrelim2016', - problem_index: '1612', + problem_index: 'E', name: '3D Printing', title: '3D Printing', }, { id: '1613', contest_id: 'ICPCPrelim2016', - problem_index: '1613', + problem_index: 'F', name: 'Deciphering Characters', title: 'Deciphering Characters', }, { id: '1614', contest_id: 'ICPCPrelim2016', - problem_index: '1614', + problem_index: 'G', name: 'Warp Drive', title: 'Warp Drive', }, { id: '1615', contest_id: 'ICPCPrelim2016', - problem_index: '1615', + problem_index: 'H', name: 'Gift Exchange Party', title: 'Gift Exchange Party', }, { id: '1616', contest_id: 'ICPCPrelim2017', - problem_index: '1616', + problem_index: 'A', name: "Taro's Shopping", title: "Taro's Shopping", }, { id: '1617', contest_id: 'ICPCPrelim2017', - problem_index: '1617', + problem_index: 'B', name: 'Almost Identical Programs', title: 'Almost Identical Programs', }, { id: '1618', contest_id: 'ICPCPrelim2017', - problem_index: '1618', + problem_index: 'C', name: 'A Garden with Ponds', title: 'A Garden with Ponds', }, { id: '1619', contest_id: 'ICPCPrelim2017', - problem_index: '1619', + problem_index: 'D', name: 'Making Lunch Boxes', title: 'Making Lunch Boxes', }, { id: '1620', contest_id: 'ICPCPrelim2017', - problem_index: '1620', + problem_index: 'E', name: 'Boolean Expression Compressor', title: 'Boolean Expression Compressor', }, { id: '1621', contest_id: 'ICPCPrelim2017', - problem_index: '1621', + problem_index: 'F', name: 'Folding a Ribbon', title: 'Folding a Ribbon', }, { id: '1622', contest_id: 'ICPCPrelim2017', - problem_index: '1622', + problem_index: 'G', name: 'Go around the Labyrinth', title: 'Go around the Labyrinth', }, { id: '1623', contest_id: 'ICPCPrelim2017', - problem_index: '1623', + problem_index: 'H', name: 'Equivalent Deformation', title: 'Equivalent Deformation', }, { id: '1624', contest_id: 'ICPCPrelim2018', - problem_index: '1624', + problem_index: 'A', name: 'Income Inequality', title: 'Income Inequality', }, { id: '1625', contest_id: 'ICPCPrelim2018', - problem_index: '1625', + problem_index: 'B', name: 'Origami, or the art of folding paper', title: 'Origami, or the art of folding paper', }, { id: '1626', contest_id: 'ICPCPrelim2018', - problem_index: '1626', + problem_index: 'C', name: 'Skyscraper MinatoHarukas', title: 'Skyscraper MinatoHarukas', }, { id: '1627', contest_id: 'ICPCPrelim2018', - problem_index: '1627', + problem_index: 'D', name: 'Playoff by all the teams', title: 'Playoff by all the teams', }, { id: '1628', contest_id: 'ICPCPrelim2018', - problem_index: '1628', + problem_index: 'E', name: 'Floating-Point Numbers', title: 'Floating-Point Numbers', }, { id: '1629', contest_id: 'ICPCPrelim2018', - problem_index: '1629', + problem_index: 'F', name: 'Equilateral Triangular Fence', title: 'Equilateral Triangular Fence', }, { id: '1630', contest_id: 'ICPCPrelim2018', - problem_index: '1630', + problem_index: 'G', name: 'Expression Mining', title: 'Expression Mining', }, { id: '1631', contest_id: 'ICPCPrelim2018', - problem_index: '1631', + problem_index: 'H', name: 'For Programming Excellence', title: 'For Programming Excellence', }, { id: '1632', contest_id: 'ICPCPrelim2019', - problem_index: '1632', + problem_index: 'A', name: 'Scores of Final Examination', title: 'Scores of Final Examination', }, { id: '1633', contest_id: 'ICPCPrelim2019', - problem_index: '1633', + problem_index: 'B', name: 'On-Screen Keyboard', title: 'On-Screen Keyboard', }, { id: '1634', contest_id: 'ICPCPrelim2019', - problem_index: '1634', + problem_index: 'C', name: 'Balance Scale', title: 'Balance Scale', }, { id: '1635', contest_id: 'ICPCPrelim2019', - problem_index: '1635', + problem_index: 'D', name: 'Tally Counters', title: 'Tally Counters', }, { id: '1636', contest_id: 'ICPCPrelim2019', - problem_index: '1636', + problem_index: 'E', name: 'Cube Surface Puzzle', title: 'Cube Surface Puzzle', }, { id: '1637', contest_id: 'ICPCPrelim2019', - problem_index: '1637', + problem_index: 'F', name: 'Flipping Colors', title: 'Flipping Colors', }, { id: '1638', contest_id: 'ICPCPrelim2019', - problem_index: '1638', + problem_index: 'G', name: "Let's Move Tiles!", title: "Let's Move Tiles!", }, { id: '1639', contest_id: 'ICPCPrelim2019', - problem_index: '1639', + problem_index: 'H', name: 'Addition on Convex Polygons', title: 'Addition on Convex Polygons', }, { id: '1640', contest_id: 'ICPCPrelim2020', - problem_index: '1640', + problem_index: 'A', name: 'Count Up 2020', title: 'Count Up 2020', }, { id: '1641', contest_id: 'ICPCPrelim2020', - problem_index: '1641', + problem_index: 'B', name: 'Contact Tracer', title: 'Contact Tracer', }, { id: '1642', contest_id: 'ICPCPrelim2020', - problem_index: '1642', + problem_index: 'C', name: 'Luggage', title: 'Luggage', }, { id: '1643', contest_id: 'ICPCPrelim2020', - problem_index: '1643', + problem_index: 'D', name: 'Icpcan Alphabet', title: 'Icpcan Alphabet', }, { id: '1644', contest_id: 'ICPCPrelim2020', - problem_index: '1644', + problem_index: 'E', name: 'Keima', title: 'Keima', }, { id: '1645', contest_id: 'ICPCPrelim2020', - problem_index: '1645', + problem_index: 'F', name: 'Evacuation Site', title: 'Evacuation Site', }, { id: '1646', contest_id: 'ICPCPrelim2020', - problem_index: '1646', + problem_index: 'G', name: 'Avoiding Three Cs', title: 'Avoiding Three Cs', }, { id: '1647', contest_id: 'ICPCPrelim2020', - problem_index: '1647', + problem_index: 'H', name: 'Idealistic Canister', title: 'Idealistic Canister', }, { id: '1648', contest_id: 'ICPCPrelim2021', - problem_index: '1648', + problem_index: 'A', name: 'Marbles Tell Your Lucky Number', title: 'Marbles Tell Your Lucky Number', }, { id: '1649', contest_id: 'ICPCPrelim2021', - problem_index: '1649', + problem_index: 'B', name: 'Hundred-Cell Calculation Puzzles', title: 'Hundred-Cell Calculation Puzzles', }, { id: '1650', contest_id: 'ICPCPrelim2021', - problem_index: '1650', + problem_index: 'C', name: 'Tree Transformation Puzzle', title: 'Tree Transformation Puzzle', }, { id: '1651', contest_id: 'ICPCPrelim2021', - problem_index: '1651', + problem_index: 'D', name: 'Handing out Balloons', title: 'Handing out Balloons', }, { id: '1652', contest_id: 'ICPCPrelim2021', - problem_index: '1652', + problem_index: 'E', name: 'Time is Money', title: 'Time is Money', }, { id: '1653', contest_id: 'ICPCPrelim2021', - problem_index: '1653', + problem_index: 'F', name: "Princess' Perfectionism", title: "Princess' Perfectionism", }, { id: '1654', contest_id: 'ICPCPrelim2021', - problem_index: '1654', + problem_index: 'G', name: 'Positioning the Lights', title: 'Positioning the Lights', }, { id: '1655', contest_id: 'ICPCPrelim2021', - problem_index: '1655', + problem_index: 'H', name: 'Ninja Escape', title: 'Ninja Escape', }, { id: '1656', contest_id: 'ICPCPrelim2022', - problem_index: '1656', + problem_index: 'A', name: 'Counting Peaks of Infection', title: 'Counting Peaks of Infection', }, { id: '1657', contest_id: 'ICPCPrelim2022', - problem_index: '1657', + problem_index: 'B', name: 'Leave No One Behind', title: 'Leave No One Behind', }, { id: '1658', contest_id: 'ICPCPrelim2022', - problem_index: '1658', + problem_index: 'C', name: 'Training Schedule for ICPC', title: 'Training Schedule for ICPC', }, { id: '1659', contest_id: 'ICPCPrelim2022', - problem_index: '1659', + problem_index: 'D', name: 'Audience Queue', title: 'Audience Queue', }, { id: '1660', contest_id: 'ICPCPrelim2022', - problem_index: '1660', + problem_index: 'E', name: 'Village of Lore', title: 'Village of Lore', }, { id: '1661', contest_id: 'ICPCPrelim2022', - problem_index: '1661', + problem_index: 'F', name: 'Trim It Step by Step', title: 'Trim It Step by Step', }, { id: '1662', contest_id: 'ICPCPrelim2022', - problem_index: '1662', + problem_index: 'G', name: 'Keep in Touch', title: 'Keep in Touch', }, { id: '1663', contest_id: 'ICPCPrelim2022', - problem_index: '1663', + problem_index: 'H', name: 'Artist in Agony', title: 'Artist in Agony', }, { id: '1664', contest_id: 'ICPCPrelim2023', - problem_index: '1664', + problem_index: 'A', name: 'Which Team Should Receive the Sponsor Prize?', title: 'Which Team Should Receive the Sponsor Prize?', }, { id: '1665', contest_id: 'ICPCPrelim2023', - problem_index: '1665', + problem_index: 'B', name: 'Amidakuji', title: 'Amidakuji', }, { id: '1666', contest_id: 'ICPCPrelim2023', - problem_index: '1666', + problem_index: 'C', name: 'Changing the Sitting Arrangement', title: 'Changing the Sitting Arrangement', }, { id: '1667', contest_id: 'ICPCPrelim2023', - problem_index: '1667', + problem_index: 'D', name: 'Efficient Problem Set', title: 'Efficient Problem Set', }, { id: '1668', contest_id: 'ICPCPrelim2023', - problem_index: '1668', + problem_index: 'E', name: 'Tampered Records', title: 'Tampered Records', }, { id: '1669', contest_id: 'ICPCPrelim2023', - problem_index: '1669', + problem_index: 'F', name: 'Villa of Emblem Shape', title: 'Villa of Emblem Shape', }, { id: '1670', contest_id: 'ICPCPrelim2023', - problem_index: '1670', + problem_index: 'G', name: 'Fair Deal of Dice', title: 'Fair Deal of Dice', }, { id: '1671', contest_id: 'ICPCPrelim2023', - problem_index: '1671', + problem_index: 'H', name: 'Planning Locations of Bus Stops', title: 'Planning Locations of Bus Stops', }, { id: '1672', contest_id: 'ICPCPrelim2024', - problem_index: '1672', + problem_index: 'A', name: 'Snacks within 300 Yen', title: 'Snacks within 300 Yen', }, { id: '1673', contest_id: 'ICPCPrelim2024', - problem_index: '1673', + problem_index: 'B', name: 'Overtaking', title: 'Overtaking', }, { id: '1674', contest_id: 'ICPCPrelim2024', - problem_index: '1674', + problem_index: 'C', name: 'Honeycomb Distance', title: 'Honeycomb Distance', }, { id: '1675', contest_id: 'ICPCPrelim2024', - problem_index: '1675', + problem_index: 'D', 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', + problem_index: 'E', name: 'Colorful Residential Area', title: 'Colorful Residential Area', }, { id: '1677', contest_id: 'ICPCPrelim2024', - problem_index: '1677', + problem_index: 'F', name: 'Billiards', title: 'Billiards', }, { id: '1678', contest_id: 'ICPCPrelim2024', - problem_index: '1678', + problem_index: 'G', name: 'Two Sets of Cards', title: 'Two Sets of Cards', }, { id: '1679', contest_id: 'ICPCPrelim2024', - problem_index: '1679', + problem_index: 'H', name: 'Blocking the Way', title: 'Blocking the Way', }, { id: '1680', contest_id: 'ICPCPrelim2024', - problem_index: '1680', + problem_index: 'I', name: 'All Survived?', title: 'All Survived?', }, { id: '1681', contest_id: 'ICPCPrelim2025', - problem_index: '1681', + problem_index: 'A', name: '2025', title: '2025', }, { id: '1682', contest_id: 'ICPCPrelim2025', - problem_index: '1682', + problem_index: 'B', name: 'Prefix and Suffix Can Be the Same', title: 'Prefix and Suffix Can Be the Same', }, { id: '1683', contest_id: 'ICPCPrelim2025', - problem_index: '1683', + problem_index: 'C', name: 'Calendar of an Enthusiastic Worker', title: 'Calendar of an Enthusiastic Worker', }, { id: '1684', contest_id: 'ICPCPrelim2025', - problem_index: '1684', + problem_index: 'D', name: 'Ancient Game Board', title: 'Ancient Game Board', }, { id: '1685', contest_id: 'ICPCPrelim2025', - problem_index: '1685', + problem_index: 'E', name: 'To Be Discontinued', title: 'To Be Discontinued', }, { id: '1686', contest_id: 'ICPCPrelim2025', - problem_index: '1686', + problem_index: 'F', name: 'Dog Tricks', title: 'Dog Tricks', }, { id: '1687', contest_id: 'ICPCPrelim2025', - problem_index: '1687', + problem_index: 'G', name: 'Number of Faces', title: 'Number of Faces', }, { id: '1688', contest_id: 'ICPCPrelim2025', - problem_index: '1688', + problem_index: 'H', name: 'Parentheses', title: 'Parentheses', }, { id: '1689', contest_id: 'ICPCPrelim2025', - problem_index: '1689', + problem_index: 'I', name: 'Preparing the Lunch', title: 'Preparing the Lunch', }, { id: '1690', contest_id: 'ICPCPrelim2026', - problem_index: '1690', + problem_index: 'A', name: 'Find the Strongest Card', title: 'Find the Strongest Card', }, { id: '1691', contest_id: 'ICPCPrelim2026', - problem_index: '1691', + problem_index: 'B', name: 'Vending Machines', title: 'Vending Machines', }, { id: '1692', contest_id: 'ICPCPrelim2026', - problem_index: '1692', + problem_index: 'C', name: 'Water Remaining', title: 'Water Remaining', }, { id: '1693', contest_id: 'ICPCPrelim2026', - problem_index: '1693', + problem_index: 'D', name: 'Frequency Sequence', title: 'Frequency Sequence', }, { id: '1694', contest_id: 'ICPCPrelim2026', - problem_index: '1694', + problem_index: 'E', name: 'Shopping Master', title: 'Shopping Master', }, { id: '1695', contest_id: 'ICPCPrelim2026', - problem_index: '1695', + problem_index: 'F', name: 'Optimizing a Map Application', title: 'Optimizing a Map Application', }, { id: '1696', contest_id: 'ICPCPrelim2026', - problem_index: '1696', + problem_index: 'G', name: 'Avoid Collision', title: 'Avoid Collision', }, { id: '1697', contest_id: 'ICPCPrelim2026', - problem_index: '1697', + problem_index: 'H', name: 'Sorting Swim Rings', title: 'Sorting Swim Rings', }, { id: '1698', contest_id: 'ICPCPrelim2026', - problem_index: '1698', + problem_index: 'I', name: 'Speed Limit', title: 'Speed Limit', }, { id: '1699', contest_id: 'ICPCPrelim2026', - problem_index: '1699', + problem_index: 'J', name: 'Maximum Scaling', title: 'Maximum Scaling', }, { id: '1200', contest_id: 'ICPCRegional1998', - problem_index: '1200', + problem_index: 'A', name: "Goldbach's Conjecture", title: "Goldbach's Conjecture", }, { id: '1201', contest_id: 'ICPCRegional1998', - problem_index: '1201', + problem_index: 'B', name: 'Lattice Practices', title: 'Lattice Practices', }, { id: '1202', contest_id: 'ICPCRegional1998', - problem_index: '1202', + problem_index: 'C', name: 'Mobile Phone Coverage', title: 'Mobile Phone Coverage', }, { id: '1203', contest_id: 'ICPCRegional1998', - problem_index: '1203', + problem_index: 'D', name: "Napoleon's Grumble", title: "Napoleon's Grumble", }, { id: '1204', contest_id: 'ICPCRegional1998', - problem_index: '1204', + problem_index: 'E', name: 'Pipeline Scheduling', title: 'Pipeline Scheduling', }, { id: '1205', contest_id: 'ICPCRegional1998', - problem_index: '1205', + problem_index: 'F', name: 'Triangle Partition', title: 'Triangle Partition', }, { id: '1206', contest_id: 'ICPCRegional1998', - problem_index: '1206', + problem_index: 'G', name: 'BUT We Need a Diagram', title: 'BUT We Need a Diagram', }, { id: '1207', contest_id: 'ICPCRegional1998', - problem_index: '1207', + problem_index: 'H', name: 'Digital Racing Circuil', title: 'Digital Racing Circuil', }, { id: '1208', contest_id: 'ICPCRegional1999', - problem_index: '1208', + problem_index: 'A', name: 'Rational Irrationals', title: 'Rational Irrationals', }, { id: '1209', contest_id: 'ICPCRegional1999', - problem_index: '1209', + problem_index: 'B', name: 'Square Coins', title: 'Square Coins', }, { id: '1210', contest_id: 'ICPCRegional1999', - problem_index: '1210', + problem_index: 'C', name: 'Die Game', title: 'Die Game', }, { id: '1211', contest_id: 'ICPCRegional1999', - problem_index: '1211', + problem_index: 'D', name: 'Trapezoids', title: 'Trapezoids', }, { id: '1212', contest_id: 'ICPCRegional1999', - problem_index: '1212', + problem_index: 'E', name: 'Mirror Illusion', title: 'Mirror Illusion', }, { id: '1213', contest_id: 'ICPCRegional1999', - problem_index: '1213', + problem_index: 'F', name: 'Heavenly Jewels', title: 'Heavenly Jewels', }, { id: '1214', contest_id: 'ICPCRegional1999', - problem_index: '1214', + problem_index: 'G', name: 'Walking Ant', title: 'Walking Ant', }, { id: '1215', contest_id: 'ICPCRegional1999', - problem_index: '1215', + problem_index: 'H', name: 'Co-occurrence Search', title: 'Co-occurrence Search', }, { id: '1216', contest_id: 'ICPCRegional2000', - problem_index: '1216', + problem_index: 'A', name: 'Lost in Space', title: 'Lost in Space', }, { id: '1217', contest_id: 'ICPCRegional2000', - problem_index: '1217', + problem_index: 'B', name: 'Family Tree', title: 'Family Tree', }, { id: '1218', contest_id: 'ICPCRegional2000', - problem_index: '1218', + problem_index: 'C', name: 'Push!!', title: 'Push!!', }, { id: '1219', contest_id: 'ICPCRegional2000', - problem_index: '1219', + problem_index: 'D', name: 'Pump up Batteries', title: 'Pump up Batteries', }, { id: '1220', contest_id: 'ICPCRegional2000', - problem_index: '1220', + problem_index: 'E', name: 'The Devil of Gravity', title: 'The Devil of Gravity', }, { id: '1221', contest_id: 'ICPCRegional2000', - problem_index: '1221', + problem_index: 'F', name: 'Numoeba', title: 'Numoeba', }, { id: '1222', contest_id: 'ICPCRegional2000', - problem_index: '1222', + problem_index: 'G', name: 'Telescope', title: 'Telescope', }, { id: '1224', contest_id: 'ICPCRegional2001', - problem_index: '1224', + problem_index: 'A', name: 'Starship Hakodate-maru', title: 'Starship Hakodate-maru', }, { id: '1225', contest_id: 'ICPCRegional2001', - problem_index: '1225', + problem_index: 'B', name: 'e-market', title: 'e-market', }, { id: '1226', contest_id: 'ICPCRegional2001', - problem_index: '1226', + problem_index: 'C', name: 'Fishnet', title: 'Fishnet', }, { id: '1227', contest_id: 'ICPCRegional2001', - problem_index: '1227', + problem_index: 'D', name: '77377', title: '77377', }, { id: '1228', contest_id: 'ICPCRegional2001', - problem_index: '1228', + problem_index: 'E', name: 'Beehives', title: 'Beehives', }, { id: '1229', contest_id: 'ICPCRegional2001', - problem_index: '1229', + problem_index: 'F', name: 'Young, Poor and Busy', title: 'Young, Poor and Busy', }, { id: '1230', contest_id: 'ICPCRegional2001', - problem_index: '1230', + problem_index: 'G', name: 'Nim', title: 'Nim', }, { id: '1231', contest_id: 'ICPCRegional2001', - problem_index: '1231', + problem_index: 'H', name: 'Super Star', title: 'Super Star', }, { id: '1232', contest_id: 'ICPCRegional2002', - problem_index: '1232', + problem_index: 'A', name: 'Calling Extraterrestrial Intelligence Again', title: 'Calling Extraterrestrial Intelligence Again', }, { id: '1233', contest_id: 'ICPCRegional2002', - problem_index: '1233', + problem_index: 'B', name: 'Equals are Equals', title: 'Equals are Equals', }, { id: '1234', contest_id: 'ICPCRegional2002', - problem_index: '1234', + problem_index: 'C', name: 'GIGA Universe Cup', title: 'GIGA Universe Cup', }, { id: '1235', contest_id: 'ICPCRegional2002', - problem_index: '1235', + problem_index: 'D', name: 'Life Line', title: 'Life Line', }, { id: '1236', contest_id: 'ICPCRegional2002', - problem_index: '1236', + problem_index: 'E', name: 'Map of Ninja House', title: 'Map of Ninja House', }, { id: '1237', contest_id: 'ICPCRegional2002', - problem_index: '1237', + problem_index: 'F', name: 'Shredding Company', title: 'Shredding Company', }, { id: '1238', contest_id: 'ICPCRegional2002', - problem_index: '1238', + problem_index: 'G', name: 'True Liars', title: 'True Liars', }, { id: '1239', contest_id: 'ICPCRegional2002', - problem_index: '1239', + problem_index: 'H', name: 'Viva Confetti', title: 'Viva Confetti', }, { id: '1240', contest_id: 'ICPCRegional2003', - problem_index: '1240', + problem_index: 'A', name: 'Unreliable Message', title: 'Unreliable Message', }, { id: '1241', contest_id: 'ICPCRegional2003', - problem_index: '1241', + problem_index: 'B', name: "Lagrange's Four-Square Theorem", title: "Lagrange's Four-Square Theorem", }, { id: '1242', contest_id: 'ICPCRegional2003', - problem_index: '1242', + problem_index: 'C', name: 'Area of Polygons', title: 'Area of Polygons', }, { id: '1243', contest_id: 'ICPCRegional2003', - problem_index: '1243', + problem_index: 'D', name: 'Weather Forecast', title: 'Weather Forecast', }, { id: '1244', contest_id: 'ICPCRegional2003', - problem_index: '1244', + problem_index: 'E', name: 'Molecular Formula', title: 'Molecular Formula', }, { id: '1245', contest_id: 'ICPCRegional2003', - problem_index: '1245', + problem_index: 'F', name: 'Gap', title: 'Gap', }, { id: '1246', contest_id: 'ICPCRegional2003', - problem_index: '1246', + problem_index: 'G', name: 'Concert Hall Scheduling', title: 'Concert Hall Scheduling', }, { id: '1247', contest_id: 'ICPCRegional2003', - problem_index: '1247', + problem_index: 'H', name: 'Monster Trap', title: 'Monster Trap', }, { id: '1248', contest_id: 'ICPCRegional2004', - problem_index: '1248', + problem_index: 'A', name: 'The Balance', title: 'The Balance', }, { id: '1249', contest_id: 'ICPCRegional2004', - problem_index: '1249', + problem_index: 'B', name: 'Make a Sequence', title: 'Make a Sequence', }, { id: '1250', contest_id: 'ICPCRegional2004', - problem_index: '1250', + problem_index: 'C', name: 'Leaky Cryptography', title: 'Leaky Cryptography', }, { id: '1251', contest_id: 'ICPCRegional2004', - problem_index: '1251', + problem_index: 'D', name: 'Pathological Paths', title: 'Pathological Paths', }, { id: '1252', contest_id: 'ICPCRegional2004', - problem_index: '1252', + problem_index: 'E', name: 'Confusing Login Names', title: 'Confusing Login Names', }, { id: '1253', contest_id: 'ICPCRegional2004', - problem_index: '1253', + problem_index: 'F', name: 'Dice Puzzle', title: 'Dice Puzzle', }, { id: '1254', contest_id: 'ICPCRegional2004', - problem_index: '1254', + problem_index: 'G', name: 'Color the Map', title: 'Color the Map', }, { id: '1255', contest_id: 'ICPCRegional2004', - problem_index: '1255', + problem_index: 'H', name: 'Inherit the Spheres', title: 'Inherit the Spheres', }, { id: '1256', contest_id: 'ICPCRegional2004', - problem_index: '1256', + problem_index: 'I', name: 'Crossing Prisms', title: 'Crossing Prisms', }, { id: '1257', contest_id: 'ICPCRegional2005', - problem_index: '1257', + problem_index: 'A', name: 'Sum of Consecutive prime Numbers', title: 'Sum of Consecutive prime Numbers', }, { id: '1258', contest_id: 'ICPCRegional2005', - problem_index: '1258', + problem_index: 'B', name: 'Book Replacement', title: 'Book Replacement', }, { id: '1259', contest_id: 'ICPCRegional2005', - problem_index: '1259', + problem_index: 'C', name: 'Colored Cubes', title: 'Colored Cubes', }, { id: '1260', contest_id: 'ICPCRegional2005', - problem_index: '1260', + problem_index: 'D', name: 'Organize Your Train', title: 'Organize Your Train', }, { id: '1261', contest_id: 'ICPCRegional2005', - problem_index: '1261', + problem_index: 'E', name: 'Mobile Computing', title: 'Mobile Computing', }, { id: '1262', contest_id: 'ICPCRegional2005', - problem_index: '1262', + problem_index: 'F', name: 'Atomic Car Race', title: 'Atomic Car Race', }, { id: '1263', contest_id: 'ICPCRegional2005', - problem_index: '1263', + problem_index: 'G', name: 'Network Mess', title: 'Network Mess', }, { id: '1264', contest_id: 'ICPCRegional2005', - problem_index: '1264', + problem_index: 'H', name: 'Bingo', title: 'Bingo', }, { id: '1265', contest_id: 'ICPCRegional2005', - problem_index: '1265', + problem_index: 'I', name: 'Shy Polygons', title: 'Shy Polygons', }, { id: '1266', contest_id: 'ICPCRegional2006', - problem_index: '1266', + problem_index: 'A', name: 'How I Wonder What You Are!', title: 'How I Wonder What You Are!', }, { id: '1267', contest_id: 'ICPCRegional2006', - problem_index: '1267', + problem_index: 'B', name: 'How I Mathematician Wonder What You Are!', title: 'How I Mathematician Wonder What You Are!', }, { id: '1268', contest_id: 'ICPCRegional2006', - problem_index: '1268', + problem_index: 'C', name: 'Cubic Eight-Puzzle', title: 'Cubic Eight-Puzzle', }, { id: '1269', contest_id: 'ICPCRegional2006', - problem_index: '1269', + problem_index: 'D', name: 'Sum of Different Primes', title: 'Sum of Different Primes', }, { id: '1270', contest_id: 'ICPCRegional2006', - problem_index: '1270', + problem_index: 'E', name: 'Manhattan Wiring', title: 'Manhattan Wiring', }, { id: '1271', contest_id: 'ICPCRegional2006', - problem_index: '1271', + problem_index: 'F', name: 'Power Calculus', title: 'Power Calculus', }, { id: '1272', contest_id: 'ICPCRegional2006', - problem_index: '1272', + problem_index: 'G', name: 'Polygons on the Grid', title: 'Polygons on the Grid', }, { id: '1273', contest_id: 'ICPCRegional2006', - problem_index: '1273', + problem_index: 'H', name: 'The Best Name for Your Baby', title: 'The Best Name for Your Baby', }, { id: '1274', contest_id: 'ICPCRegional2006', - problem_index: '1274', + problem_index: 'I', name: 'Enjoyable Commutation', title: 'Enjoyable Commutation', }, { id: '1275', contest_id: 'ICPCRegional2007', - problem_index: '1275', + problem_index: 'A', name: 'And Then There Was One', title: 'And Then There Was One', }, { id: '1276', contest_id: 'ICPCRegional2007', - problem_index: '1276', + problem_index: 'B', name: 'Prime Gap', title: 'Prime Gap', }, { id: '1277', contest_id: 'ICPCRegional2007', - problem_index: '1277', + problem_index: 'C', name: 'Minimal Backgammon', title: 'Minimal Backgammon', }, { id: '1278', contest_id: 'ICPCRegional2007', - problem_index: '1278', + problem_index: 'D', name: 'Lowest Pyramid', title: 'Lowest Pyramid', }, { id: '1279', contest_id: 'ICPCRegional2007', - problem_index: '1279', + problem_index: 'E', name: 'Geometric Map', title: 'Geometric Map', }, { id: '1280', contest_id: 'ICPCRegional2007', - problem_index: '1280', + problem_index: 'F', name: 'Slim Span', title: 'Slim Span', }, { id: '1281', contest_id: 'ICPCRegional2007', - problem_index: '1281', + problem_index: 'G', name: 'The Morning after Halloween', title: 'The Morning after Halloween', }, { id: '1282', contest_id: 'ICPCRegional2007', - problem_index: '1282', + problem_index: 'H', name: 'Bug Hunt', title: 'Bug Hunt', }, { id: '1283', contest_id: 'ICPCRegional2007', - problem_index: '1283', + problem_index: 'I', name: 'Most Distant Point from the Sea', title: 'Most Distant Point from the Sea', }, { id: '1284', contest_id: 'ICPCRegional2007', - problem_index: '1284', + problem_index: 'J', name: "The Teacher's Side of Math", title: "The Teacher's Side of Math", }, { id: '1285', contest_id: 'ICPCRegional2008', - problem_index: '1285', + problem_index: 'A', name: 'Grey Area', title: 'Grey Area', }, { id: '1286', contest_id: 'ICPCRegional2008', - problem_index: '1286', + problem_index: 'B', name: 'Expected Allowance', title: 'Expected Allowance', }, { id: '1287', contest_id: 'ICPCRegional2008', - problem_index: '1287', + problem_index: 'C', name: 'Stopped Watches', title: 'Stopped Watches', }, { id: '1288', contest_id: 'ICPCRegional2008', - problem_index: '1288', + problem_index: 'D', name: 'Digits on the Floor', title: 'Digits on the Floor', }, { id: '1289', contest_id: 'ICPCRegional2008', - problem_index: '1289', + problem_index: 'E', name: 'Spherical Mirrors', title: 'Spherical Mirrors', }, { id: '1290', contest_id: 'ICPCRegional2008', - problem_index: '1290', + problem_index: 'F', name: 'Traveling Cube', title: 'Traveling Cube', }, { id: '1291', contest_id: 'ICPCRegional2008', - problem_index: '1291', + problem_index: 'G', name: 'Search of Concatenated Strings', title: 'Search of Concatenated Strings', }, { id: '1292', contest_id: 'ICPCRegional2008', - problem_index: '1292', + problem_index: 'H', name: 'Top Spinning', title: 'Top Spinning', }, { id: '1293', contest_id: 'ICPCRegional2008', - problem_index: '1293', + problem_index: 'I', name: 'Common Polynomial', title: 'Common Polynomial', }, { id: '1294', contest_id: 'ICPCRegional2008', - problem_index: '1294', + problem_index: 'J', name: 'Zigzag', title: 'Zigzag', }, { id: '1295', contest_id: 'ICPCRegional2009', - problem_index: '1295', + problem_index: 'A', name: 'Cubist Artwork', title: 'Cubist Artwork', }, { id: '1296', contest_id: 'ICPCRegional2009', - problem_index: '1296', + problem_index: 'B', name: 'Repeated Substitution with Sed', title: 'Repeated Substitution with Sed', }, { id: '1297', contest_id: 'ICPCRegional2009', - problem_index: '1297', + problem_index: 'C', name: 'Swimming Jam', title: 'Swimming Jam', }, { id: '1298', contest_id: 'ICPCRegional2009', - problem_index: '1298', + problem_index: 'D', name: 'Separate Points', title: 'Separate Points', }, { id: '1299', contest_id: 'ICPCRegional2009', - problem_index: '1299', + problem_index: 'E', name: 'Origami Through-Hole', title: 'Origami Through-Hole', }, { id: '1300', contest_id: 'ICPCRegional2009', - problem_index: '1300', + problem_index: 'F', name: "Chemist's Math", title: "Chemist's Math", }, { id: '1301', contest_id: 'ICPCRegional2009', - problem_index: '1301', + problem_index: 'G', name: 'Malfatti Circles', title: 'Malfatti Circles', }, { id: '1302', contest_id: 'ICPCRegional2009', - problem_index: '1302', + problem_index: 'H', name: 'Twenty Questions', title: 'Twenty Questions', }, { id: '1303', contest_id: 'ICPCRegional2009', - problem_index: '1303', + problem_index: 'I', name: 'Hobby on Rails', title: 'Hobby on Rails', }, { id: '1304', contest_id: 'ICPCRegional2009', - problem_index: '1304', + problem_index: 'J', name: 'Infected Land', title: 'Infected Land', }, { id: '1305', contest_id: 'ICPCRegional2010', - problem_index: '1305', + problem_index: 'A', name: 'Membership Management', title: 'Membership Management', }, { id: '1306', contest_id: 'ICPCRegional2010', - problem_index: '1306', + problem_index: 'B', name: 'Balloon Collecting', title: 'Balloon Collecting', }, { id: '1307', contest_id: 'ICPCRegional2010', - problem_index: '1307', + problem_index: 'C', name: 'Towns along a Highway', title: 'Towns along a Highway', }, { id: '1308', contest_id: 'ICPCRegional2010', - problem_index: '1308', + problem_index: 'D', name: 'Awkward Lights', title: 'Awkward Lights', }, { id: '1309', contest_id: 'ICPCRegional2010', - problem_index: '1309', + problem_index: 'E', name: 'The Two Men of the Japanese Alps', title: 'The Two Men of the Japanese Alps', }, { id: '1310', contest_id: 'ICPCRegional2010', - problem_index: '1310', + problem_index: 'F', name: 'Find the Multiples', title: 'Find the Multiples', }, { id: '1311', contest_id: 'ICPCRegional2010', - problem_index: '1311', + problem_index: 'G', name: 'Test Case Tweaking', title: 'Test Case Tweaking', }, { id: '1312', contest_id: 'ICPCRegional2010', - problem_index: '1312', + problem_index: 'H', name: "Where's Wally", title: "Where's Wally", }, { id: '1313', contest_id: 'ICPCRegional2010', - problem_index: '1313', + problem_index: 'I', name: 'Intersection of Two Prisms', title: 'Intersection of Two Prisms', }, { id: '1314', contest_id: 'ICPCRegional2010', - problem_index: '1314', + problem_index: 'J', name: 'Matrix Calculator', title: 'Matrix Calculator', }, { id: '1315', contest_id: 'ICPCRegional2011', - problem_index: '1315', + problem_index: 'A', name: 'Gift from the Goddess of Programming', title: 'Gift from the Goddess of Programming', }, { id: '1316', contest_id: 'ICPCRegional2011', - problem_index: '1316', + problem_index: 'B', name: "The Sorcerer's Donut", title: "The Sorcerer's Donut", }, { id: '1317', contest_id: 'ICPCRegional2011', - problem_index: '1317', + problem_index: 'C', name: 'Weaker than Planned', title: 'Weaker than Planned', }, { id: '1318', contest_id: 'ICPCRegional2011', - problem_index: '1318', + problem_index: 'D', name: 'Long Distance Taxi', title: 'Long Distance Taxi', }, { id: '1319', contest_id: 'ICPCRegional2011', - problem_index: '1319', + problem_index: 'E', name: 'Driving an Icosahedral Rover', title: 'Driving an Icosahedral Rover', }, { id: '1320', contest_id: 'ICPCRegional2011', - problem_index: '1320', + problem_index: 'F', name: 'City Merger', title: 'City Merger', }, { id: '1321', contest_id: 'ICPCRegional2011', - problem_index: '1321', + problem_index: 'G', name: "Captain Q's Treasure", title: "Captain Q's Treasure", }, { id: '1322', contest_id: 'ICPCRegional2011', - problem_index: '1322', + problem_index: 'H', name: 'ASCII Expression', title: 'ASCII Expression', }, { id: '1323', contest_id: 'ICPCRegional2011', - problem_index: '1323', + problem_index: 'I', name: 'Encircling Circles', title: 'Encircling Circles', }, { id: '1324', contest_id: 'ICPCRegional2011', - problem_index: '1324', + problem_index: 'J', name: 'Round Trip', title: 'Round Trip', }, { id: '1325', contest_id: 'ICPCRegional2012', - problem_index: '1325', + problem_index: 'A', name: 'Ginkgo Numbers', title: 'Ginkgo Numbers', }, { id: '1326', contest_id: 'ICPCRegional2012', - problem_index: '1326', + problem_index: 'B', name: 'Stylish', title: 'Stylish', }, { id: '1327', contest_id: 'ICPCRegional2012', - problem_index: '1327', + problem_index: 'C', name: 'One-Dimensional Cellular Automaton', title: 'One-Dimensional Cellular Automaton', }, { id: '1328', contest_id: 'ICPCRegional2012', - problem_index: '1328', + problem_index: 'D', name: 'Find the Outlier', title: 'Find the Outlier', }, { id: '1329', contest_id: 'ICPCRegional2012', - problem_index: '1329', + problem_index: 'E', name: 'Sliding Block Puzzle', title: 'Sliding Block Puzzle', }, { id: '1330', contest_id: 'ICPCRegional2012', - problem_index: '1330', + problem_index: 'F', name: 'Never Wait for Weights', title: 'Never Wait for Weights', }, { id: '1331', contest_id: 'ICPCRegional2012', - problem_index: '1331', + problem_index: 'G', name: 'Let There Be Light', title: 'Let There Be Light', }, { id: '1332', contest_id: 'ICPCRegional2012', - problem_index: '1332', + problem_index: 'H', name: 'Company Organization', title: 'Company Organization', }, { id: '1333', contest_id: 'ICPCRegional2012', - problem_index: '1333', + problem_index: 'I', name: 'Beautiful Spacing', title: 'Beautiful Spacing', }, { id: '1334', contest_id: 'ICPCRegional2012', - problem_index: '1334', + problem_index: 'J', name: 'Cubic Colonies', title: 'Cubic Colonies', }, { id: '1335', contest_id: 'ICPCRegional2013', - problem_index: '1335', + problem_index: 'A', name: 'Equal Sum Sets', title: 'Equal Sum Sets', }, { id: '1336', contest_id: 'ICPCRegional2013', - problem_index: '1336', + problem_index: 'B', name: 'The Last Ant', title: 'The Last Ant', }, { id: '1337', contest_id: 'ICPCRegional2013', - problem_index: '1337', + problem_index: 'C', name: 'Count the Regions', title: 'Count the Regions', }, { id: '1338', contest_id: 'ICPCRegional2013', - problem_index: '1338', + problem_index: 'D', name: 'Clock Hands', title: 'Clock Hands', }, { id: '1339', contest_id: 'ICPCRegional2013', - problem_index: '1339', + problem_index: 'E', name: "Dragon's Cruller", title: "Dragon's Cruller", }, { id: '1340', contest_id: 'ICPCRegional2013', - problem_index: '1340', + problem_index: 'F', name: 'Directional Resemblance', title: 'Directional Resemblance', }, { id: '1341', contest_id: 'ICPCRegional2013', - problem_index: '1341', + problem_index: 'G', name: 'Longest Chain', title: 'Longest Chain', }, { id: '1342', contest_id: 'ICPCRegional2013', - problem_index: '1342', + problem_index: 'H', name: "Don't Burst the Balloon", title: "Don't Burst the Balloon", }, { id: '1343', contest_id: 'ICPCRegional2013', - problem_index: '1343', + problem_index: 'I', name: 'Hidden Tree', title: 'Hidden Tree', }, { id: '1344', contest_id: 'ICPCRegional2013', - problem_index: '1344', + problem_index: 'J', name: 'C(O|W|A*RD*|S)* CROSSWORD Puzzle', title: 'C(O|W|A*RD*|S)* CROSSWORD Puzzle', }, { id: '1345', contest_id: 'ICPCRegional2014', - problem_index: '1345', + problem_index: 'A', name: 'Bit String Reordering', title: 'Bit String Reordering', }, { id: '1346', contest_id: 'ICPCRegional2014', - problem_index: '1346', + problem_index: 'B', name: 'Miscalculation', title: 'Miscalculation', }, { id: '1347', contest_id: 'ICPCRegional2014', - problem_index: '1347', + problem_index: 'C', name: 'Shopping', title: 'Shopping', }, { id: '1348', contest_id: 'ICPCRegional2014', - problem_index: '1348', + problem_index: 'D', name: 'Space Golf', title: 'Space Golf', }, { id: '1349', contest_id: 'ICPCRegional2014', - problem_index: '1349', + problem_index: 'E', name: 'Automotive Navigation', title: 'Automotive Navigation', }, { id: '1350', contest_id: 'ICPCRegional2014', - problem_index: '1350', + problem_index: 'F', name: 'There is No Alternative', title: 'There is No Alternative', }, { id: '1351', contest_id: 'ICPCRegional2014', - problem_index: '1351', + problem_index: 'G', name: 'Flipping Parentheses', title: 'Flipping Parentheses', }, { id: '1352', contest_id: 'ICPCRegional2014', - problem_index: '1352', + problem_index: 'H', name: 'Cornering at Poles', title: 'Cornering at Poles', }, { id: '1353', contest_id: 'ICPCRegional2014', - problem_index: '1353', + problem_index: 'I', name: 'Sweet War', title: 'Sweet War', }, { id: '1354', contest_id: 'ICPCRegional2014', - problem_index: '1354', + problem_index: 'J', name: 'Exhibition', title: 'Exhibition', }, { id: '1355', contest_id: 'ICPCRegional2014', - problem_index: '1355', + problem_index: 'K', name: 'L Jumps', title: 'L Jumps', }, { id: '1356', contest_id: 'ICPCRegional2015', - problem_index: '1356', + problem_index: 'A', name: 'Decimal Sequences', title: 'Decimal Sequences', }, { id: '1357', contest_id: 'ICPCRegional2015', - problem_index: '1357', + problem_index: 'B', name: 'Squeeze the Cylinders', title: 'Squeeze the Cylinders', }, { id: '1358', contest_id: 'ICPCRegional2015', - problem_index: '1358', + problem_index: 'C', name: 'Sibling Rivalry', title: 'Sibling Rivalry', }, { id: '1359', contest_id: 'ICPCRegional2015', - problem_index: '1359', + problem_index: 'D', name: 'Wall Clocks', title: 'Wall Clocks', }, { id: '1360', contest_id: 'ICPCRegional2015', - problem_index: '1360', + problem_index: 'E', name: 'Bringing Order to Disorder', title: 'Bringing Order to Disorder', }, { id: '1361', contest_id: 'ICPCRegional2015', - problem_index: '1361', + problem_index: 'F', name: 'Deadlock Detection', title: 'Deadlock Detection', }, { id: '1362', contest_id: 'ICPCRegional2015', - problem_index: '1362', + problem_index: 'G', name: 'Do Geese See God?', title: 'Do Geese See God?', }, { id: '1363', contest_id: 'ICPCRegional2015', - problem_index: '1363', + problem_index: 'H', name: 'Rotating Cutter Bits', title: 'Rotating Cutter Bits', }, { id: '1364', contest_id: 'ICPCRegional2015', - problem_index: '1364', + problem_index: 'I', name: 'Routing a Marathon Race', title: 'Routing a Marathon Race', }, { id: '1365', contest_id: 'ICPCRegional2015', - problem_index: '1365', + problem_index: 'J', name: 'Post Office Investigation', title: 'Post Office Investigation', }, { id: '1366', contest_id: 'ICPCRegional2015', - problem_index: '1366', + problem_index: 'K', name: 'Min-Max Distance Game', title: 'Min-Max Distance Game', }, { id: '1367', contest_id: 'ICPCRegional2016', - problem_index: '1367', + problem_index: 'A', name: 'Rearranging a Sequence', title: 'Rearranging a Sequence', }, { id: '1368', contest_id: 'ICPCRegional2016', - problem_index: '1368', + problem_index: 'B', name: 'Quality of Check Digits', title: 'Quality of Check Digits', }, { id: '1369', contest_id: 'ICPCRegional2016', - problem_index: '1369', + problem_index: 'C', name: 'Distribution Center', title: 'Distribution Center', }, { id: '1370', contest_id: 'ICPCRegional2016', - problem_index: '1370', + problem_index: 'D', name: 'Hidden Anagrams', title: 'Hidden Anagrams', }, { id: '1371', contest_id: 'ICPCRegional2016', - problem_index: '1371', + problem_index: 'E', name: 'Infallibly Crack Perplexing Cryptarithm', title: 'Infallibly Crack Perplexing Cryptarithm', }, { id: '1372', contest_id: 'ICPCRegional2016', - problem_index: '1372', + problem_index: 'F', name: 'Three Kingdoms of Bourdelot', title: 'Three Kingdoms of Bourdelot', }, { id: '1373', contest_id: 'ICPCRegional2016', - problem_index: '1373', + problem_index: 'G', name: 'Placing Medals on a Binary Tree', title: 'Placing Medals on a Binary Tree', }, { id: '1374', contest_id: 'ICPCRegional2016', - problem_index: '1374', + problem_index: 'H', name: 'Animal Companion in Maze', title: 'Animal Companion in Maze', }, { id: '1375', contest_id: 'ICPCRegional2016', - problem_index: '1375', + problem_index: 'I', name: 'Skinny Polygon', title: 'Skinny Polygon', }, { id: '1376', contest_id: 'ICPCRegional2016', - problem_index: '1376', + problem_index: 'J', name: 'Cover the Polygon with Your Disk', title: 'Cover the Polygon with Your Disk', }, { id: '1377', contest_id: 'ICPCRegional2016', - problem_index: '1377', + problem_index: 'K', name: 'Black and White Boxes', title: 'Black and White Boxes', }, { id: '1378', contest_id: 'ICPCRegional2017', - problem_index: '1378', + problem_index: 'A', name: 'Secret of Chocolate Poles', title: 'Secret of Chocolate Poles', }, { id: '1379', contest_id: 'ICPCRegional2017', - problem_index: '1379', + problem_index: 'B', name: 'Parallel Lines', title: 'Parallel Lines', }, { id: '1380', contest_id: 'ICPCRegional2017', - problem_index: '1380', + problem_index: 'C', name: 'Medical Checkup', title: 'Medical Checkup', }, { id: '1381', contest_id: 'ICPCRegional2017', - problem_index: '1381', + problem_index: 'D', name: 'Making Perimeter of the Convex Hull Shortest', title: 'Making Perimeter of the Convex Hull Shortest', }, { id: '1382', contest_id: 'ICPCRegional2017', - problem_index: '1382', + problem_index: 'E', name: 'Black or White', title: 'Black or White', }, { id: '1383', contest_id: 'ICPCRegional2017', - problem_index: '1383', + problem_index: 'F', name: 'Pizza Delivery', title: 'Pizza Delivery', }, { id: '1384', contest_id: 'ICPCRegional2017', - problem_index: '1384', + problem_index: 'G', name: 'Rendezvous on a Tetrahedron', title: 'Rendezvous on a Tetrahedron', }, { id: '1385', contest_id: 'ICPCRegional2017', - problem_index: '1385', + problem_index: 'H', name: 'Homework', title: 'Homework', }, { id: '1386', contest_id: 'ICPCRegional2017', - problem_index: '1386', + problem_index: 'I', name: 'Starting a Scenic Railroad Service', title: 'Starting a Scenic Railroad Service', }, { id: '1387', contest_id: 'ICPCRegional2017', - problem_index: '1387', + problem_index: 'J', name: 'String Puzzle', title: 'String Puzzle', }, { id: '1388', contest_id: 'ICPCRegional2017', - problem_index: '1388', + problem_index: 'K', name: 'Counting Cycles', title: 'Counting Cycles', }, { id: '1389', contest_id: 'ICPCRegional2018', - problem_index: '1389', + problem_index: 'A', name: 'Digits Are Not Just Characters', title: 'Digits Are Not Just Characters', }, { id: '1390', contest_id: 'ICPCRegional2018', - problem_index: '1390', + problem_index: 'B', name: 'Arithmetic Progressions', title: 'Arithmetic Progressions', }, { id: '1391', contest_id: 'ICPCRegional2018', - problem_index: '1391', + problem_index: 'C', name: 'Emergency Evacuation', title: 'Emergency Evacuation', }, { id: '1392', contest_id: 'ICPCRegional2018', - problem_index: '1392', + problem_index: 'D', name: 'Shortest Common Non-Subsequence', title: 'Shortest Common Non-Subsequence', }, { id: '1393', contest_id: 'ICPCRegional2018', - problem_index: '1393', + problem_index: 'E', name: 'Eulerian Flight Tour', title: 'Eulerian Flight Tour', }, { id: '1394', contest_id: 'ICPCRegional2018', - problem_index: '1394', + problem_index: 'F', name: 'Fair Chocolate-Cutting', title: 'Fair Chocolate-Cutting', }, { id: '1395', contest_id: 'ICPCRegional2018', - problem_index: '1395', + problem_index: 'G', name: 'What Goes Up Must Come Down', title: 'What Goes Up Must Come Down', }, { id: '1396', contest_id: 'ICPCRegional2018', - problem_index: '1396', + problem_index: 'H', name: 'Four-Coloring', title: 'Four-Coloring', }, { id: '1397', contest_id: 'ICPCRegional2018', - problem_index: '1397', + problem_index: 'I', name: 'Ranks', title: 'Ranks', }, { id: '1398', contest_id: 'ICPCRegional2018', - problem_index: '1398', + problem_index: 'J', name: 'Colorful Tree', title: 'Colorful Tree', }, { id: '1399', contest_id: 'ICPCRegional2018', - problem_index: '1399', + problem_index: 'K', name: 'Sixth Sense', title: 'Sixth Sense', }, { id: '1400', contest_id: 'ICPCRegional2019', - problem_index: '1400', + problem_index: 'A', name: 'Fast Forwarding', title: 'Fast Forwarding', }, { id: '1401', contest_id: 'ICPCRegional2019', - problem_index: '1401', + problem_index: 'B', name: 'Estimating the Flood Risk', title: 'Estimating the Flood Risk', }, { id: '1402', contest_id: 'ICPCRegional2019', - problem_index: '1402', + problem_index: 'C', name: 'Wall Painting', title: 'Wall Painting', }, { id: '1403', contest_id: 'ICPCRegional2019', - problem_index: '1403', + problem_index: 'D', name: 'Twin Trees Bros.', title: 'Twin Trees Bros.', }, { id: '1404', contest_id: 'ICPCRegional2019', - problem_index: '1404', + problem_index: 'E', name: 'Reordering the Documents', title: 'Reordering the Documents', }, { id: '1405', contest_id: 'ICPCRegional2019', - problem_index: '1405', + problem_index: 'F', name: 'Halting Problem', title: 'Halting Problem', }, { id: '1406', contest_id: 'ICPCRegional2019', - problem_index: '1406', + problem_index: 'G', name: 'Ambiguous Encoding', title: 'Ambiguous Encoding', }, { id: '1407', contest_id: 'ICPCRegional2019', - problem_index: '1407', + problem_index: 'H', name: 'Parentheses Editor', title: 'Parentheses Editor', }, { id: '1408', contest_id: 'ICPCRegional2019', - problem_index: '1408', + problem_index: 'I', name: 'One-Way Conveyors', title: 'One-Way Conveyors', }, { id: '1409', contest_id: 'ICPCRegional2019', - problem_index: '1409', + problem_index: 'J', name: 'Fun Region', title: 'Fun Region', }, { id: '1410', contest_id: 'ICPCRegional2019', - problem_index: '1410', + problem_index: 'K', name: 'Draw in Straight Lines', title: 'Draw in Straight Lines', }, { id: '1411', contest_id: 'ICPCRegional2020', - problem_index: '1411', + problem_index: 'A', name: 'Three-Axis Views', title: 'Three-Axis Views', }, { id: '1412', contest_id: 'ICPCRegional2020', - problem_index: '1412', + problem_index: 'B', name: 'Secrets of Legendary Treasure', title: 'Secrets of Legendary Treasure', }, { id: '1413', contest_id: 'ICPCRegional2020', - problem_index: '1413', + problem_index: 'C', name: 'Short Coding', title: 'Short Coding', }, { id: '1414', contest_id: 'ICPCRegional2020', - problem_index: '1414', + problem_index: 'D', name: 'Colorful Rectangle', title: 'Colorful Rectangle', }, { id: '1415', contest_id: 'ICPCRegional2020', - problem_index: '1415', + problem_index: 'E', name: 'Jewelry Size', title: 'Jewelry Size', }, { id: '1416', contest_id: 'ICPCRegional2020', - problem_index: '1416', + problem_index: 'F', name: 'Solar Car', title: 'Solar Car', }, { id: '1417', contest_id: 'ICPCRegional2020', - problem_index: '1417', + problem_index: 'G', name: 'To be Connected or not to be that is the Question', title: 'To be Connected or not to be that is the Question', }, { id: '1418', contest_id: 'ICPCRegional2020', - problem_index: '1418', + problem_index: 'H', name: 'LCM of GCDs', title: 'LCM of GCDs', }, { id: '1419', contest_id: 'ICPCRegional2020', - problem_index: '1419', + problem_index: 'I', name: 'High-Tech Detective', title: 'High-Tech Detective', }, { id: '1420', contest_id: 'ICPCRegional2020', - problem_index: '1420', + problem_index: 'J', name: 'Formica Sokobanica', title: 'Formica Sokobanica', }, { id: '1421', contest_id: 'ICPCRegional2020', - problem_index: '1421', + problem_index: 'K', name: 'Suffixes may Contain Prefixes', title: 'Suffixes may Contain Prefixes', }, { id: '1422', contest_id: 'ICPCRegional2021', - problem_index: '1422', + problem_index: 'A', name: 'Loop of Chocolate', title: 'Loop of Chocolate', }, { id: '1423', contest_id: 'ICPCRegional2021', - problem_index: '1423', + problem_index: 'B', name: 'Lottery Fun Time', title: 'Lottery Fun Time', }, { id: '1424', contest_id: 'ICPCRegional2021', - problem_index: '1424', + problem_index: 'C', name: 'Reversible Compressio', title: 'Reversible Compressio', }, { id: '1425', contest_id: 'ICPCRegional2021', - problem_index: '1425', + problem_index: 'D', name: 'Wireless Communication Network', title: 'Wireless Communication Network', }, { id: '1426', contest_id: 'ICPCRegional2021', - problem_index: '1426', + problem_index: 'E', name: 'Planning Railroad Discontinuation', title: 'Planning Railroad Discontinuation', }, { id: '1427', contest_id: 'ICPCRegional2021', - problem_index: '1427', + problem_index: 'F', name: 'It’s Surely Complex', title: 'It’s Surely Complex', }, { id: '1428', contest_id: 'ICPCRegional2021', - problem_index: '1428', + problem_index: 'G', name: 'Genealogy of Puppets', title: 'Genealogy of Puppets', }, { id: '1429', contest_id: 'ICPCRegional2021', - problem_index: '1429', + problem_index: 'H', name: 'Cancer DNA', title: 'Cancer DNA', }, { id: '1430', contest_id: 'ICPCRegional2021', - problem_index: '1430', + problem_index: 'I', name: 'Even Division', title: 'Even Division', }, { id: '1431', contest_id: 'ICPCRegional2021', - problem_index: '1431', + problem_index: 'J', name: 'The Cross Covers Everything', title: 'The Cross Covers Everything', }, { id: '1432', contest_id: 'ICPCRegional2021', - problem_index: '1432', + problem_index: 'K', name: 'Distributing the Treasure', title: 'Distributing the Treasure', }, { id: '1433', contest_id: 'ICPCRegional2022', - problem_index: '1433', + problem_index: 'A', name: 'Hasty Santa Claus', title: 'Hasty Santa Claus', }, { id: '1434', contest_id: 'ICPCRegional2022', - problem_index: '1434', + problem_index: 'B', name: 'Interactive Number Guessing', title: 'Interactive Number Guessing', }, { id: '1435', contest_id: 'ICPCRegional2022', - problem_index: '1435', + problem_index: 'C', name: 'Secure the Top Secret', title: 'Secure the Top Secret', }, { id: '1436', contest_id: 'ICPCRegional2022', - problem_index: '1436', + problem_index: 'D', name: 'Move One Coin', title: 'Move One Coin', }, { id: '1437', contest_id: 'ICPCRegional2022', - problem_index: '1437', + problem_index: 'E', name: 'Incredibly Cute Penguin Chicks', title: 'Incredibly Cute Penguin Chicks', }, { id: '1438', contest_id: 'ICPCRegional2022', - problem_index: '1438', + problem_index: 'F', name: 'Make a Loop', title: 'Make a Loop', }, { id: '1439', contest_id: 'ICPCRegional2022', - problem_index: '1439', + problem_index: 'G', name: 'Remodeling the Dungeon', title: 'Remodeling the Dungeon', }, { id: '1440', contest_id: 'ICPCRegional2022', - problem_index: '1440', + problem_index: 'H', name: 'Cake Decoration', title: 'Cake Decoration', }, { id: '1441', contest_id: 'ICPCRegional2022', - problem_index: '1441', + problem_index: 'I', name: 'Quiz Contest', title: 'Quiz Contest', }, { id: '1442', contest_id: 'ICPCRegional2022', - problem_index: '1442', + problem_index: 'J', name: 'Traveling Salesperson in an Island', title: 'Traveling Salesperson in an Island', }, { id: '1443', contest_id: 'ICPCRegional2022', - problem_index: '1443', + problem_index: 'K', name: 'New Year Festival', title: 'New Year Festival', }, { id: '1444', contest_id: 'ICPCRegional2023', - problem_index: '1444', + problem_index: 'A', name: 'Yokohama Phenomena', title: 'Yokohama Phenomena', }, { id: '1445', contest_id: 'ICPCRegional2023', - problem_index: '1445', + problem_index: 'B', name: 'Rank Promotion', title: 'Rank Promotion', }, { id: '1446', contest_id: 'ICPCRegional2023', - problem_index: '1446', + problem_index: 'C', name: 'Ferris Wheel', title: 'Ferris Wheel', }, { id: '1447', contest_id: 'ICPCRegional2023', - problem_index: '1447', + problem_index: 'D', name: 'Nested Repetition Compression', title: 'Nested Repetition Compression', }, { id: '1448', contest_id: 'ICPCRegional2023', - problem_index: '1448', + problem_index: 'E', name: 'Chayas', title: 'Chayas', }, { id: '1449', contest_id: 'ICPCRegional2023', - problem_index: '1449', + problem_index: 'F', name: 'Color Inversion on a Huge Chessboard', title: 'Color Inversion on a Huge Chessboard', }, { id: '1450', contest_id: 'ICPCRegional2023', - problem_index: '1450', + problem_index: 'G', name: 'Fortune Telling', title: 'Fortune Telling', }, { id: '1451', contest_id: 'ICPCRegional2023', - problem_index: '1451', + problem_index: 'H', name: 'Task Assignment to Two Employees', title: 'Task Assignment to Two Employees', }, { id: '1452', contest_id: 'ICPCRegional2023', - problem_index: '1452', + problem_index: 'I', name: 'Liquid Distribution', title: 'Liquid Distribution', }, { id: '1453', contest_id: 'ICPCRegional2023', - problem_index: '1453', + problem_index: 'J', name: 'Do It Yourself?', title: 'Do It Yourself?', }, { id: '1454', contest_id: 'ICPCRegional2023', - problem_index: '1454', + problem_index: 'K', name: 'Probing the Disk', title: 'Probing the Disk', }, { id: '1455', contest_id: 'ICPCRegional2024', - problem_index: '1455', + problem_index: 'A', name: 'Ribbon on the Christmas Present', title: 'Ribbon on the Christmas Present', }, { id: '1456', contest_id: 'ICPCRegional2024', - problem_index: '1456', + problem_index: 'B', name: 'The Sparsest Number in Between', title: 'The Sparsest Number in Between', }, { id: '1457', contest_id: 'ICPCRegional2024', - problem_index: '1457', + problem_index: 'C', name: 'Omnes Viae Yokohamam Ducunt?', title: 'Omnes Viae Yokohamam Ducunt?', }, { id: '1458', contest_id: 'ICPCRegional2024', - problem_index: '1458', + problem_index: 'D', name: 'Tree Generators', title: 'Tree Generators', }, { id: '1459', contest_id: 'ICPCRegional2024', - problem_index: '1459', + problem_index: 'E', name: 'E-Circuit Is Now on Sale!', title: 'E-Circuit Is Now on Sale!', }, { id: '1460', contest_id: 'ICPCRegional2024', - problem_index: '1460', + problem_index: 'F', name: 'The Farthest Point', title: 'The Farthest Point', }, { id: '1461', contest_id: 'ICPCRegional2024', - problem_index: '1461', + problem_index: 'G', name: 'Beyond the Former Explorer', title: 'Beyond the Former Explorer', }, { id: '1462', contest_id: 'ICPCRegional2024', - problem_index: '1462', + problem_index: 'H', name: 'Remodeling the Dungeon 2', title: 'Remodeling the Dungeon 2', }, { id: '1463', contest_id: 'ICPCRegional2024', - problem_index: '1463', + problem_index: 'I', name: 'Greatest of the Greatest Common Divisors', title: 'Greatest of the Greatest Common Divisors', }, { id: '1464', contest_id: 'ICPCRegional2024', - problem_index: '1464', + problem_index: 'J', name: 'Mixing Solutions', title: 'Mixing Solutions', }, { id: '1465', contest_id: 'ICPCRegional2024', - problem_index: '1465', + problem_index: 'K', name: 'Scheduling Two Meetings', title: 'Scheduling Two Meetings', }, { id: '1466', contest_id: 'ICPCRegional2024', - problem_index: '1466', + problem_index: 'L', name: 'Peculiar Protocol', title: 'Peculiar Protocol', }, { id: '2006', contest_id: 'JAGPrelim2005', - problem_index: '2006', + problem_index: 'A', name: 'Keitai Message', title: 'Keitai Message', }, { id: '2007', contest_id: 'JAGPrelim2005', - problem_index: '2007', + problem_index: 'B', name: 'Make Purse Light', title: 'Make Purse Light', }, { id: '2008', contest_id: 'JAGPrelim2005', - problem_index: '2008', + problem_index: 'C', name: 'Dragon Fantasy', title: 'Dragon Fantasy', }, { id: '2009', contest_id: 'JAGPrelim2005', - problem_index: '2009', + problem_index: 'D', name: 'Area Separation', title: 'Area Separation', }, { id: '2010', contest_id: 'JAGPrelim2005', - problem_index: '2010', + problem_index: 'E', name: 'Poor Mail Forwarding', title: 'Poor Mail Forwarding', }, { id: '2011', contest_id: 'JAGPrelim2005', - problem_index: '2011', + problem_index: 'F', name: 'Gather the Maps!', title: 'Gather the Maps!', }, { id: '2000', contest_id: 'JAGPrelim2006', - problem_index: '2000', + problem_index: 'A', name: 'Misterious Gems', title: 'Misterious Gems', }, { id: '2001', contest_id: 'JAGPrelim2006', - problem_index: '2001', + problem_index: 'B', name: 'Amida, the City of Miracle', title: 'Amida, the City of Miracle', }, { id: '2002', contest_id: 'JAGPrelim2006', - problem_index: '2002', + problem_index: 'C', name: 'X-Ray Screening System', title: 'X-Ray Screening System', }, { id: '2003', contest_id: 'JAGPrelim2006', - problem_index: '2003', + problem_index: 'D', name: 'Railroad Conflict', title: 'Railroad Conflict', }, { id: '2004', contest_id: 'JAGPrelim2006', - problem_index: '2004', + problem_index: 'E', name: 'Data Center on Fire', title: 'Data Center on Fire', }, { id: '2005', contest_id: 'JAGPrelim2006', - problem_index: '2005', + problem_index: 'F', name: 'Water Pipe Construction', title: 'Water Pipe Construction', }, { id: '2012', contest_id: 'JAGPrelim2007', - problem_index: '2012', + problem_index: 'A', name: 'Space Coconut Grab', title: 'Space Coconut Grab', }, { id: '2013', contest_id: 'JAGPrelim2007', - problem_index: '2013', + problem_index: 'B', name: 'Osaki', title: 'Osaki', }, { id: '2014', contest_id: 'JAGPrelim2007', - problem_index: '2014', + problem_index: 'C', name: 'Surrounding Area', title: 'Surrounding Area', }, { id: '2015', contest_id: 'JAGPrelim2007', - problem_index: '2015', + problem_index: 'D', name: 'Square Route', title: 'Square Route', }, { id: '2016', contest_id: 'JAGPrelim2007', - problem_index: '2016', + problem_index: 'E', name: 'Lifeguard in the Pool', title: 'Lifeguard in the Pool', }, { id: '2017', contest_id: 'JAGPrelim2007', - problem_index: '2017', + problem_index: 'F', name: 'Karakuri Doll', title: 'Karakuri Doll', }, { id: '2018', contest_id: 'JAGPrelim2008', - problem_index: '2018', + problem_index: 'A', name: "Princess's Gamble", title: "Princess's Gamble", }, { id: '2019', contest_id: 'JAGPrelim2008', - problem_index: '2019', + problem_index: 'B', name: "Princess's Marriage", title: "Princess's Marriage", }, { id: '2020', contest_id: 'JAGPrelim2008', - problem_index: '2020', + problem_index: 'C', name: "Princess's Japanese", title: "Princess's Japanese", }, { id: '2021', contest_id: 'JAGPrelim2008', - problem_index: '2021', + problem_index: 'D', name: 'Princess in Danger', title: 'Princess in Danger', }, { id: '2022', contest_id: 'JAGPrelim2008', - problem_index: '2022', + problem_index: 'E', name: 'Princess, a Cryptanalyst', title: 'Princess, a Cryptanalyst', }, { id: '2023', contest_id: 'JAGPrelim2008', - problem_index: '2023', + problem_index: 'F', name: 'Princess, a Strategist', title: 'Princess, a Strategist', }, { id: '2149', contest_id: 'JAGPrelim2009', - problem_index: '2149', + problem_index: 'A', name: 'Luck Manipulator', title: 'Luck Manipulator', }, { id: '2150', contest_id: 'JAGPrelim2009', - problem_index: '2150', + problem_index: 'B', name: 'Matsuzaki Number', title: 'Matsuzaki Number', }, { id: '2151', contest_id: 'JAGPrelim2009', - problem_index: '2151', + problem_index: 'C', name: 'Brave Princess Revisited', title: 'Brave Princess Revisited', }, { id: '2152', contest_id: 'JAGPrelim2009', - problem_index: '2152', + problem_index: 'D', name: 'Restrictive Filesystem', title: 'Restrictive Filesystem', }, { id: '2153', contest_id: 'JAGPrelim2009', - problem_index: '2153', + problem_index: 'E', name: 'Mirror Cave', title: 'Mirror Cave', }, { id: '2154', contest_id: 'JAGPrelim2009', - problem_index: '2154', + problem_index: 'F', name: 'Shore Erosion', title: 'Shore Erosion', }, { id: '2197', contest_id: 'JAGPrelim2010', - problem_index: '2197', + problem_index: 'A', name: 'Sum of Consecutive Integers', title: 'Sum of Consecutive Integers', }, { id: '2198', contest_id: 'JAGPrelim2010', - problem_index: '2198', + problem_index: 'B', name: 'Moonlight Farm', title: 'Moonlight Farm', }, { id: '2199', contest_id: 'JAGPrelim2010', - problem_index: '2199', + problem_index: 'C', name: 'Differential Pulse Code Modulation', title: 'Differential Pulse Code Modulation', }, { id: '2200', contest_id: 'JAGPrelim2010', - problem_index: '2200', + problem_index: 'D', name: 'Mr. Rito Post Office', title: 'Mr. Rito Post Office', }, { id: '2201', contest_id: 'JAGPrelim2010', - problem_index: '2201', + problem_index: 'E', name: 'Immortal Jewels', title: 'Immortal Jewels', }, { id: '2202', contest_id: 'JAGPrelim2010', - problem_index: '2202', + problem_index: 'F', name: 'Canal: Water Going Up and Down', title: 'Canal: Water Going Up and Down', }, { id: '2203', contest_id: 'JAGPrelim2010', - problem_index: '2203', + problem_index: 'G', name: 'Magical Island 2', title: 'Magical Island 2', }, { id: '2252', contest_id: 'JAGPrelim2011', - problem_index: '2252', + problem_index: 'A', name: 'koukyoukoukokukikou', title: 'koukyoukoukokukikou', }, { id: '2253', contest_id: 'JAGPrelim2011', - problem_index: '2253', + problem_index: 'B', name: 'Brave Force Story', title: 'Brave Force Story', }, { id: '2254', contest_id: 'JAGPrelim2011', - problem_index: '2254', + problem_index: 'C', name: 'Fastest Route', title: 'Fastest Route', }, { id: '2255', contest_id: 'JAGPrelim2011', - problem_index: '2255', + problem_index: 'D', name: '6/2(1+2)', title: '6/2(1+2)', }, { id: '2256', contest_id: 'JAGPrelim2011', - problem_index: '2256', + problem_index: 'E', name: 'Divide the Cake', title: 'Divide the Cake', }, { id: '2257', contest_id: 'JAGPrelim2011', - problem_index: '2257', + problem_index: 'F', name: 'Sakura Poetry', title: 'Sakura Poetry', }, { id: '2258', contest_id: 'JAGPrelim2011', - problem_index: '2258', + problem_index: 'G', name: 'Intelligent Circular Perfect Cleaner', title: 'Intelligent Circular Perfect Cleaner', }, { id: '2399', contest_id: 'JAGPrelim2012', - problem_index: '2399', + problem_index: 'A', name: 'Save Your Privacy!', title: 'Save Your Privacy!', }, { id: '2400', contest_id: 'JAGPrelim2012', - problem_index: '2400', + problem_index: 'B', name: 'You Are the Judge', title: 'You Are the Judge', }, { id: '2401', contest_id: 'JAGPrelim2012', - problem_index: '2401', + problem_index: 'C', name: 'Equation', title: 'Equation', }, { id: '2402', contest_id: 'JAGPrelim2012', - problem_index: '2402', + problem_index: 'D', name: 'Milky Way', title: 'Milky Way', }, { id: '2403', contest_id: 'JAGPrelim2012', - problem_index: '2403', + problem_index: 'E', name: 'The Enemy of My Enemy is My Friend', title: 'The Enemy of My Enemy is My Friend', }, { id: '2404', contest_id: 'JAGPrelim2012', - problem_index: '2404', + problem_index: 'F', name: 'Dog Food', title: 'Dog Food', }, { id: '2405', contest_id: 'JAGPrelim2012', - problem_index: '2405', + problem_index: 'G', name: 'Sister Ports', title: 'Sister Ports', }, { id: '2508', contest_id: 'JAGPrelim2013', - problem_index: '2508', + problem_index: 'A', name: "King's Inspection", title: "King's Inspection", }, { id: '2509', contest_id: 'JAGPrelim2013', - problem_index: '2509', + problem_index: 'B', name: 'Sim Forest 2013', title: 'Sim Forest 2013', }, { id: '2510', contest_id: 'JAGPrelim2013', - problem_index: '2510', + problem_index: 'C', name: 'Twin book report', title: 'Twin book report', }, { id: '2511', contest_id: 'JAGPrelim2013', - problem_index: '2511', + problem_index: 'D', name: 'Sinking islands', title: 'Sinking islands', }, { id: '2512', contest_id: 'JAGPrelim2013', - problem_index: '2512', + problem_index: 'E', name: "Ononokomachi's Edit War", title: "Ononokomachi's Edit War", }, { id: '2513', contest_id: 'JAGPrelim2013', - problem_index: '2513', + problem_index: 'F', name: "Princess Tetra's Puzzle", title: "Princess Tetra's Puzzle", }, { id: '2514', contest_id: 'JAGPrelim2013', - problem_index: '2514', + problem_index: 'G', name: 'MirrorLabyrinth', title: 'MirrorLabyrinth', }, { id: '2582', contest_id: 'JAGPrelim2014', - problem_index: '2582', + problem_index: 'A', name: 'Step Aerobics', title: 'Step Aerobics', }, { id: '2583', contest_id: 'JAGPrelim2014', - problem_index: '2583', + problem_index: 'B', name: 'JAG-channel', title: 'JAG-channel', }, { id: '2584', contest_id: 'JAGPrelim2014', - problem_index: '2584', + problem_index: 'C', name: 'Broken Cipher Generator', title: 'Broken Cipher Generator', }, { id: '2585', contest_id: 'JAGPrelim2014', - problem_index: '2585', + problem_index: 'D', name: '1 Day Passport', title: '1 Day Passport', }, { id: '2586', contest_id: 'JAGPrelim2014', - problem_index: '2586', + problem_index: 'E', name: 'Wish upon a shooting star', title: 'Wish upon a shooting star', }, { id: '2587', contest_id: 'JAGPrelim2014', - problem_index: '2587', + problem_index: 'F', name: 'Elevator Hall Number', title: 'Elevator Hall Number', }, { id: '2588', contest_id: 'JAGPrelim2014', - problem_index: '2588', + problem_index: 'G', name: 'Golf', title: 'Golf', }, { id: '2699', contest_id: 'JAGPrelim2015', - problem_index: '2699', + problem_index: 'A', name: 'Koto Municipal Subway', title: 'Koto Municipal Subway', }, { id: '2700', contest_id: 'JAGPrelim2015', - problem_index: '2700', + problem_index: 'B', name: 'Airport Codes', title: 'Airport Codes', }, { id: '2701', contest_id: 'JAGPrelim2015', - problem_index: '2701', + problem_index: 'C', name: 'Falling Block Puzzle', title: 'Falling Block Puzzle', }, { id: '2702', contest_id: 'JAGPrelim2015', - problem_index: '2702', + problem_index: 'D', name: 'Alternate Escape', title: 'Alternate Escape', }, { id: '2703', contest_id: 'JAGPrelim2015', - problem_index: '2703', + problem_index: 'E', name: 'Dice Stamp', title: 'Dice Stamp', }, { id: '2704', contest_id: 'JAGPrelim2015', - problem_index: '2704', + problem_index: 'F', name: 'Stamp Rally', title: 'Stamp Rally', }, { id: '2705', contest_id: 'JAGPrelim2015', - problem_index: '2705', + problem_index: 'G', name: 'Kuru Kuru Door', title: 'Kuru Kuru Door', }, { id: '2738', contest_id: 'JAGPrelim2016A', - problem_index: '2738', + problem_index: 'A', name: 'A-un Breathing', title: 'A-un Breathing', }, { id: '2739', contest_id: 'JAGPrelim2016A', - problem_index: '2739', + problem_index: 'B', name: 'Delivery to a Luxurious House', title: 'Delivery to a Luxurious House', }, { id: '2740', contest_id: 'JAGPrelim2016A', - problem_index: '2740', + problem_index: 'C', name: 'Rooted Tree for Misawa-san', title: 'Rooted Tree for Misawa-san', }, { id: '2741', contest_id: 'JAGPrelim2016A', - problem_index: '2741', + problem_index: 'D', name: 'Invisible', title: 'Invisible', }, { id: '2742', contest_id: 'JAGPrelim2016A', - problem_index: '2742', + problem_index: 'E', name: 'Campaign', title: 'Campaign', }, { id: '2743', contest_id: 'JAGPrelim2016A', - problem_index: '2743', + problem_index: 'F', name: 'Land Inheritance', title: 'Land Inheritance', }, { id: '2744', contest_id: 'JAGPrelim2016A', - problem_index: '2744', + problem_index: 'G', name: 'Rings and Strings', title: 'Rings and Strings', }, { id: '2745', contest_id: 'JAGPrelim2016B', - problem_index: '2745', + problem_index: 'A', name: 'Curry Making', title: 'Curry Making', }, { id: '2746', contest_id: 'JAGPrelim2016B', - problem_index: '2746', + problem_index: 'B', name: 'jfen', title: 'jfen', }, { id: '2747', contest_id: 'JAGPrelim2016B', - problem_index: '2747', + problem_index: 'C', name: 'Curtain', title: 'Curtain', }, { id: '2748', contest_id: 'JAGPrelim2016B', - problem_index: '2748', + problem_index: 'D', name: 'Early Morning Work at Summer Camp', title: 'Early Morning Work at Summer Camp', }, { id: '2749', contest_id: 'JAGPrelim2016B', - problem_index: '2749', + problem_index: 'E', name: 'The Most Powerful Bed', title: 'The Most Powerful Bed', }, { id: '2750', contest_id: 'JAGPrelim2016B', - problem_index: '2750', + problem_index: 'F', name: 'Hyakunin Isshu', title: 'Hyakunin Isshu', }, { id: '2751', contest_id: 'JAGPrelim2016B', - problem_index: '2751', + problem_index: 'G', name: 'Baseball', title: 'Baseball', }, { id: '2823', contest_id: 'JAGPrelim2017', - problem_index: '2823', + problem_index: 'A', name: 'JAG Practice Contest', title: 'JAG Practice Contest', }, { id: '2824', contest_id: 'JAGPrelim2017', - problem_index: '2824', + problem_index: 'B', name: 'Coastline', title: 'Coastline', }, { id: '2825', contest_id: 'JAGPrelim2017', - problem_index: '2825', + problem_index: 'C', name: 'Quiz', title: 'Quiz', }, { id: '2826', contest_id: 'JAGPrelim2017', - problem_index: '2826', + problem_index: 'D', name: 'Game Balance', title: 'Game Balance', }, { id: '2827', contest_id: 'JAGPrelim2017', - problem_index: '2827', + problem_index: 'E', name: 'Industrial Convex Pillar City', title: 'Industrial Convex Pillar City', }, { id: '2828', contest_id: 'JAGPrelim2017', - problem_index: '2828', + problem_index: 'F', name: 'Matryoshka Doll', title: 'Matryoshka Doll', }, { id: '2829', contest_id: 'JAGPrelim2017', - problem_index: '2829', + problem_index: 'G', name: 'Room Assignment', title: 'Room Assignment', }, { id: '2830', contest_id: 'JAGPrelim2017', - problem_index: '2830', + problem_index: 'H', name: 'Big Maze', title: 'Big Maze', }, { id: '2881', contest_id: 'JAGPrelim2018', - problem_index: '2881', + problem_index: 'A', name: 'Change of the Era Name', title: 'Change of the Era Name', }, { id: '2882', contest_id: 'JAGPrelim2018', - problem_index: '2882', + problem_index: 'B', name: 'Generalized Leap Years', title: 'Generalized Leap Years', }, { id: '2883', contest_id: 'JAGPrelim2018', - problem_index: '2883', + problem_index: 'C', name: 'Proof of Knowledge', title: 'Proof of Knowledge', }, { id: '2884', contest_id: 'JAGPrelim2018', - problem_index: '2884', + problem_index: 'D', name: 'Tanka Number', title: 'Tanka Number', }, { id: '2885', contest_id: 'JAGPrelim2018', - problem_index: '2885', + problem_index: 'E', name: 'Divide and Conquer', title: 'Divide and Conquer', }, { id: '2886', contest_id: 'JAGPrelim2018', - problem_index: '2886', + problem_index: 'F', name: 'Antiaircraft Shield', title: 'Antiaircraft Shield', }, { id: '2887', contest_id: 'JAGPrelim2018', - problem_index: '2887', + problem_index: 'G', name: 'Casino', title: 'Casino', }, { id: '2888', contest_id: 'JAGPrelim2018', - problem_index: '2888', + problem_index: 'H', name: 'NINJA GAME', title: 'NINJA GAME', }, { id: '2944', contest_id: 'JAGPrelim2019', - problem_index: '2944', + problem_index: 'A', name: 'Equal Split', title: 'Equal Split', }, { id: '2945', contest_id: 'JAGPrelim2019', - problem_index: '2945', + problem_index: 'B', name: 'Poison Swamp', title: 'Poison Swamp', }, { id: '2946', contest_id: 'JAGPrelim2019', - problem_index: '2946', + problem_index: 'C', name: 'You Should Avoid', title: 'You Should Avoid', }, { id: '2947', contest_id: 'JAGPrelim2019', - problem_index: '2947', + problem_index: 'D', name: 'String Magic', title: 'String Magic', }, { id: '2948', contest_id: 'JAGPrelim2019', - problem_index: '2948', + problem_index: 'E', name: 'Great Strategy for Bring Up Grade', title: 'Great Strategy for Bring Up Grade', }, { id: '2949', contest_id: 'JAGPrelim2019', - problem_index: '2949', + problem_index: 'F', name: 'The Genome Database of All Space Life Returns', title: 'The Genome Database of All Space Life Returns', }, { id: '2950', contest_id: 'JAGPrelim2019', - problem_index: '2950', + problem_index: 'G', name: 'K is Key of Takakkey', title: 'K is Key of Takakkey', }, { id: '2951', contest_id: 'JAGPrelim2019', - problem_index: '2951', + problem_index: 'H', name: 'Misterious Buttons', title: 'Misterious Buttons', }, { id: '3201', contest_id: 'JAGPrelim2020', - problem_index: '3201', + problem_index: 'A', name: 'nm Calculations', title: 'nm Calculations', }, { id: '3202', contest_id: 'JAGPrelim2020', - problem_index: '3202', + problem_index: 'B', name: 'Chains of Explosions', title: 'Chains of Explosions', }, { id: '3203', contest_id: 'JAGPrelim2020', - problem_index: '3203', + problem_index: 'C', name: 'Sneaking', title: 'Sneaking', }, { id: '3204', contest_id: 'JAGPrelim2020', - problem_index: '3204', + problem_index: 'D', name: 'Almost Balanced Parentheses', title: 'Almost Balanced Parentheses', }, { id: '3205', contest_id: 'JAGPrelim2020', - problem_index: '3205', + problem_index: 'E', name: 'Edamame Energy Engineering', title: 'Edamame Energy Engineering', }, { id: '3206', contest_id: 'JAGPrelim2020', - problem_index: '3206', + problem_index: 'F', name: 'Arrow Dice', title: 'Arrow Dice', }, { id: '3207', contest_id: 'JAGPrelim2020', - problem_index: '3207', + problem_index: 'G', name: 'Kyopro is Useful for Asteroid Exploration', title: 'Kyopro is Useful for Asteroid Exploration', }, { id: '3208', contest_id: 'JAGPrelim2020', - problem_index: '3208', + problem_index: 'H', name: 'Edge or Vertex', title: 'Edge or Vertex', }, { id: '3246', contest_id: 'JAGPrelim2021', - problem_index: '3246', + problem_index: 'A', name: 'Tokyo2020', title: 'Tokyo2020', }, { id: '3247', contest_id: 'JAGPrelim2021', - problem_index: '3247', + problem_index: 'B', name: 'Food Delivery', title: 'Food Delivery', }, { id: '3248', contest_id: 'JAGPrelim2021', - problem_index: '3248', + problem_index: 'C', name: 'Moving', title: 'Moving', }, { id: '3249', contest_id: 'JAGPrelim2021', - problem_index: '3249', + problem_index: 'D', name: 'Buri Shabu Shaburi Club', title: 'Buri Shabu Shaburi Club', }, { id: '3250', contest_id: 'JAGPrelim2021', - problem_index: '3250', + problem_index: 'E', name: 'Rotation', title: 'Rotation', }, { id: '3251', contest_id: 'JAGPrelim2021', - problem_index: '3251', + problem_index: 'F', name: 'One is Zero, Zero is One', title: 'One is Zero, Zero is One', }, { id: '3252', contest_id: 'JAGPrelim2021', - problem_index: '3252', + problem_index: 'G', name: 'Cookie Game', title: 'Cookie Game', }, { id: '3253', contest_id: 'JAGPrelim2021', - problem_index: '3253', + problem_index: 'H', name: 'Skewering', title: 'Skewering', }, { id: '3292', contest_id: 'JAGPrelim2022', - problem_index: '3292', + problem_index: 'A', name: 'Serial Number', title: 'Serial Number', }, { id: '3293', contest_id: 'JAGPrelim2022', - problem_index: '3293', + problem_index: 'B', name: 'Maximizing Pocket Money', title: 'Maximizing Pocket Money', }, { id: '3294', contest_id: 'JAGPrelim2022', - problem_index: '3294', + problem_index: 'C', name: 'i18n', title: 'i18n', }, { id: '3295', contest_id: 'JAGPrelim2022', - problem_index: '3295', + problem_index: 'D', name: 'Moving Point P and Funny Friends Q, R', title: 'Moving Point P and Funny Friends Q, R', }, { id: '3296', contest_id: 'JAGPrelim2022', - problem_index: '3296', + problem_index: 'E', name: 'Greatest Common Divisor', title: 'Greatest Common Divisor', }, { id: '3297', contest_id: 'JAGPrelim2022', - problem_index: '3297', + problem_index: 'F', name: 'Nth Floor Dungeon and K Brave Men', title: 'Nth Floor Dungeon and K Brave Men', }, { id: '3298', contest_id: 'JAGPrelim2022', - problem_index: '3298', + problem_index: 'G', name: 'Traveling Brave Problem', title: 'Traveling Brave Problem', }, { id: '3299', contest_id: 'JAGPrelim2022', - problem_index: '3299', + problem_index: 'H', name: 'Parade', title: 'Parade', }, { id: '3358', contest_id: 'JAGPrelim2023', - problem_index: '3358', + problem_index: 'A', name: 'Sorting Crayons', title: 'Sorting Crayons', }, { id: '3359', contest_id: 'JAGPrelim2023', - problem_index: '3359', + problem_index: 'B', name: 'Everyday is Holiday', title: 'Everyday is Holiday', }, { id: '3360', contest_id: 'JAGPrelim2023', - problem_index: '3360', + problem_index: 'C', name: 'Game', title: 'Game', }, { id: '3361', contest_id: 'JAGPrelim2023', - problem_index: '3361', + problem_index: 'D', name: 'IPvK', title: 'IPvK', }, { id: '3362', contest_id: 'JAGPrelim2023', - problem_index: '3362', + problem_index: 'E', name: 'Watch the Marathon', title: 'Watch the Marathon', }, { id: '3363', contest_id: 'JAGPrelim2023', - problem_index: '3363', + problem_index: 'F', name: 'Frog in a Well', title: 'Frog in a Well', }, { id: '3364', contest_id: 'JAGPrelim2023', - problem_index: '3364', + problem_index: 'G', name: 'Watch the Movie', title: 'Watch the Movie', }, { id: '3365', contest_id: 'JAGPrelim2023', - problem_index: '3365', + problem_index: 'H', name: 'Orienteering', title: 'Orienteering', }, { id: '3386', contest_id: 'JAGPrelim2024', - problem_index: '3386', + problem_index: 'A', name: 'Transition of Club Members', title: 'Transition of Club Members', }, { id: '3387', contest_id: 'JAGPrelim2024', - problem_index: '3387', + problem_index: 'B', name: 'Simple Editor', title: 'Simple Editor', }, { id: '3388', contest_id: 'JAGPrelim2024', - problem_index: '3388', + problem_index: 'C', name: 'Advertisement in Star Cluster', title: 'Advertisement in Star Cluster', }, { id: '3389', contest_id: 'JAGPrelim2024', - problem_index: '3389', + problem_index: 'D', name: 'Sharing Past Problems', title: 'Sharing Past Problems', }, { id: '3390', contest_id: 'JAGPrelim2024', - problem_index: '3390', + problem_index: 'E', name: 'Roller Coaster 2', title: 'Roller Coaster 2', }, { id: '3391', contest_id: 'JAGPrelim2024', - problem_index: '3391', + problem_index: 'F', name: 'Synthesis of Slimes', title: 'Synthesis of Slimes', }, { id: '3392', contest_id: 'JAGPrelim2024', - problem_index: '3392', + problem_index: 'G', name: 'Segmentation of a Sequence', title: 'Segmentation of a Sequence', }, { id: '3393', contest_id: 'JAGPrelim2024', - problem_index: '3393', + problem_index: 'H', name: 'Physics Experiment', title: 'Physics Experiment', }, { id: '3394', contest_id: 'JAGPrelim2024', - problem_index: '3394', + problem_index: 'I', name: 'Bridge Construction Plan 2', title: 'Bridge Construction Plan 2', }, { id: '3443', contest_id: 'JAGPrelim2025', - problem_index: '3443', + problem_index: 'A', name: 'Mex Prize', title: 'Mex Prize', }, { id: '3444', contest_id: 'JAGPrelim2025', - problem_index: '3444', + problem_index: 'B', name: 'Flight Planning', title: 'Flight Planning', }, { id: '3445', contest_id: 'JAGPrelim2025', - problem_index: '3445', + problem_index: 'C', name: 'Today is the contest', title: 'Today is the contest', }, { id: '3446', contest_id: 'JAGPrelim2025', - problem_index: '3446', + problem_index: 'D', name: 'Guess the parentheses', title: 'Guess the parentheses', }, { id: '3447', contest_id: 'JAGPrelim2025', - problem_index: '3447', + problem_index: 'E', name: 'if-then-else', title: 'if-then-else', }, { id: '3448', contest_id: 'JAGPrelim2025', - problem_index: '3448', + problem_index: 'F', name: 'Anti-BCC', title: 'Anti-BCC', }, { id: '3449', contest_id: 'JAGPrelim2025', - problem_index: '3449', + problem_index: 'G', name: "Hanako's Art", title: "Hanako's Art", }, { id: '3450', contest_id: 'JAGPrelim2025', - problem_index: '3450', + problem_index: 'H', name: 'Product of Permutations', title: 'Product of Permutations', }, { id: '3451', contest_id: 'JAGPrelim2025', - problem_index: '3451', + problem_index: 'I', name: "Let's Make the Best Cake", title: "Let's Make the Best Cake", }, { id: '3452', contest_id: 'JAGPrelim2026', - problem_index: '3452', + problem_index: 'A', name: 'Booby Prize', title: 'Booby Prize', }, { id: '3453', contest_id: 'JAGPrelim2026', - problem_index: '3453', + problem_index: 'B', name: 'Rearrange', title: 'Rearrange', }, { id: '3454', contest_id: 'JAGPrelim2026', - problem_index: '3454', + problem_index: 'C', name: 'Loooong Vacation', title: 'Loooong Vacation', }, { id: '3455', contest_id: 'JAGPrelim2026', - problem_index: '3455', + problem_index: 'D', name: 'Box Tower', title: 'Box Tower', }, { id: '3456', contest_id: 'JAGPrelim2026', - problem_index: '3456', + problem_index: 'E', name: 'Secret Numbers and the Greatest Common Divisor', title: 'Secret Numbers and the Greatest Common Divisor', }, { id: '3457', contest_id: 'JAGPrelim2026', - problem_index: '3457', + problem_index: 'F', name: 'ICPC is a Contest', title: 'ICPC is a Contest', }, { id: '3458', contest_id: 'JAGPrelim2026', - problem_index: '3458', + problem_index: 'G', name: 'XOR Travel Plans', title: 'XOR Travel Plans', }, { id: '3459', contest_id: 'JAGPrelim2026', - problem_index: '3459', + problem_index: 'H', name: 'Stone-Picking Game', title: 'Stone-Picking Game', }, { id: '3460', contest_id: 'JAGPrelim2026', - problem_index: '3460', + problem_index: 'I', name: "Let's Build an Evil Robot Army", title: "Let's Build an Evil Robot Army", }, @@ -17865,6 +17865,6 @@ export const tasks = [ contest_id: 'AOJ-KUPC2011', problem_index: 'I', name: 'Mountain', - title: 'I. Mountain', + title: 'Mountain', }, ]; diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index 302492f9b..18091fbf6 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -68,7 +68,6 @@ contestIds: Array; metadata: ContestTableMetaData; displayConfig: ContestTableDisplayConfig; - taskLabels: Record>; } let contestTableMaps = $derived(prepareContestTablesMap(providers)); @@ -102,7 +101,6 @@ contestIds: provider.getContestRoundIds(filteredTaskResults), metadata: provider.getMetadata(), displayConfig: provider.getDisplayConfig(), - taskLabels: provider.getTaskLabels(filteredTaskResults), }; } @@ -298,7 +296,6 @@ {#each contestTable.headerIds as taskTableHeaderId (taskTableHeaderId)} {@const taskResult = contestTable.innerTaskTable[contestId][taskTableHeaderId]} - {@const taskLabel = contestTable.taskLabels[contestId]?.[taskTableHeaderId]} handleUpdateTaskResult(updatedTask)} /> {/if} diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index af899be68..6ca4bd8a4 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -6,15 +6,17 @@ import ExternalLinkWrapper from '#lib/components/ExternalLinkWrapper.svelte'; import UpdatingDropdown from '#lib/components/SubmissionStatus/UpdatingDropdown.svelte'; - import { getTaskUrl, removeTaskIndexFromTitle } from '#lib/utils/task.js'; - import { formatAojTitle } from '#features/tasks/utils/contest-table/aoj_labels.js'; + import { + getTaskUrl, + removeTaskIndexFromTitle, + buildTitleWithTaskIndex, + } from '#lib/utils/task.js'; interface Props { taskResult: TaskResult; isLoggedIn: boolean; isAtCoderVerified: boolean; isShownTaskIndex: boolean; - taskLabel?: string; voteResults: VoteStatisticsMap; showNeutralBadge?: boolean; onupdate?: (updatedTask: TaskResult) => void; // Ensure to update task result in parent component. @@ -25,24 +27,17 @@ isLoggedIn, isAtCoderVerified, isShownTaskIndex, - taskLabel, voteResults, showNeutralBadge = true, onupdate = () => {}, }: Props = $props(); let estimatedGrade = $derived(voteResults.get(taskResult.task_id)?.grade); - let displayTitle = $derived.by(() => { - if (taskLabel) { - return formatAojTitle(taskResult.title, taskLabel); - } - - if (isShownTaskIndex) { - return taskResult.title; - } - - return removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index); - }); + let displayTitle = $derived( + isShownTaskIndex + ? buildTitleWithTaskIndex(taskResult.title, taskResult.task_table_index) + : removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index), + );
>} Nested label map, or `{}` when unused - */ - getTaskLabels(filteredTaskResults: TaskResults): Record>; } /** 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 7da77a820..461aa9e45 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 @@ -1,9 +1,8 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { describe, test, expect } from 'vitest'; import { ContestType } from '#lib/contests/types/contest.js'; import type { TaskResults } from '#lib/types/task.js'; -import { AOJ_LABEL_OVERRIDES } from './aoj_labels'; import { AojIcpcPrelimProvider, AojIcpcRegionalProvider } from './aoj_icpc_providers'; const createProvider = (year: number) => new AojIcpcPrelimProvider(ContestType.AOJ_ICPC, year); @@ -13,25 +12,25 @@ const tasks1998: TaskResults = [ { contest_id: 'ICPCPrelim1998', task_id: '1100', - task_table_index: '1100', + task_table_index: 'A', title: 'Area of Polygons', }, { contest_id: 'ICPCPrelim1998', task_id: '1101', - task_table_index: '1101', + task_table_index: 'B', title: 'A Simple Offline Text Editor', }, { contest_id: 'ICPCPrelim1998', task_id: '1102', - task_table_index: '1102', + task_table_index: 'C', title: 'Calculation of Expressions', }, { contest_id: 'ICPCPrelim1998', task_id: '1103', - task_table_index: '1103', + task_table_index: 'D', title: 'Board Arrangements for Concentration Games', }, ] as TaskResults; @@ -41,44 +40,44 @@ const tasks2023: TaskResults = [ { contest_id: 'ICPCPrelim2023', task_id: '1664', - task_table_index: '1664', + task_table_index: 'A', 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: '1665', task_table_index: 'B', title: 'Amidakuji' }, { contest_id: 'ICPCPrelim2023', task_id: '1666', - task_table_index: '1666', + task_table_index: 'C', title: 'Changing the Sitting Arrangement', }, { contest_id: 'ICPCPrelim2023', task_id: '1667', - task_table_index: '1667', + task_table_index: 'D', title: 'Efficient Problem Set', }, { contest_id: 'ICPCPrelim2023', task_id: '1668', - task_table_index: '1668', + task_table_index: 'E', title: 'Tampered Records', }, { contest_id: 'ICPCPrelim2023', task_id: '1669', - task_table_index: '1669', + task_table_index: 'F', title: 'Villa of Emblem Shape', }, { contest_id: 'ICPCPrelim2023', task_id: '1670', - task_table_index: '1670', + task_table_index: 'G', title: 'Fair Deal of Dice', }, { contest_id: 'ICPCPrelim2023', task_id: '1671', - task_table_index: '1671', + task_table_index: 'H', title: 'Planning Locations of Bus Stops', }, ] as TaskResults; @@ -88,56 +87,56 @@ const tasks2026: TaskResults = [ { contest_id: 'ICPCPrelim2026', task_id: '1690', - task_table_index: '1690', + task_table_index: 'A', title: 'Find the Strongest Card', }, { contest_id: 'ICPCPrelim2026', task_id: '1691', - task_table_index: '1691', + task_table_index: 'B', title: 'Vending Machines', }, { contest_id: 'ICPCPrelim2026', task_id: '1692', - task_table_index: '1692', + task_table_index: 'C', title: 'Water Remaining', }, { contest_id: 'ICPCPrelim2026', task_id: '1693', - task_table_index: '1693', + task_table_index: 'D', title: 'Frequency Sequence', }, { contest_id: 'ICPCPrelim2026', task_id: '1694', - task_table_index: '1694', + task_table_index: 'E', title: 'Shopping Master', }, { contest_id: 'ICPCPrelim2026', task_id: '1695', - task_table_index: '1695', + task_table_index: 'F', title: 'Optimizing a Map Application', }, { contest_id: 'ICPCPrelim2026', task_id: '1696', - task_table_index: '1696', + task_table_index: 'G', title: 'Avoid Collision', }, { contest_id: 'ICPCPrelim2026', task_id: '1697', - task_table_index: '1697', + task_table_index: 'H', title: 'Sorting Swim Rings', }, - { contest_id: 'ICPCPrelim2026', task_id: '1698', task_table_index: '1698', title: 'Speed Limit' }, + { contest_id: 'ICPCPrelim2026', task_id: '1698', task_table_index: 'I', title: 'Speed Limit' }, { contest_id: 'ICPCPrelim2026', task_id: '1699', - task_table_index: '1699', + task_table_index: 'J', title: 'Maximum Scaling', }, ] as TaskResults; @@ -148,7 +147,7 @@ const mixedTasks: TaskResults = [ { contest_id: 'ICPCPrelim2022', task_id: '1656', - task_table_index: '1656', + task_table_index: 'A', title: 'Counting Peaks of Infection', }, // Non-ICPC contest, to verify contest-type filtering @@ -199,16 +198,16 @@ describe('AojIcpcPrelimProvider', () => { test('stores raw titles (no letter prefix) for all 8 tasks', () => { const table = provider2023.generateTable(tasks2023); - expect(table['ICPCPrelim2023']['1664'].title).toBe( + expect(table['ICPCPrelim2023']['A'].title).toBe( 'Which Team Should Receive the Sponsor Prize?', ); - expect(table['ICPCPrelim2023']['1665'].title).toBe('Amidakuji'); - expect(table['ICPCPrelim2023']['1666'].title).toBe('Changing the Sitting Arrangement'); - expect(table['ICPCPrelim2023']['1667'].title).toBe('Efficient Problem Set'); - expect(table['ICPCPrelim2023']['1668'].title).toBe('Tampered Records'); - expect(table['ICPCPrelim2023']['1669'].title).toBe('Villa of Emblem Shape'); - expect(table['ICPCPrelim2023']['1670'].title).toBe('Fair Deal of Dice'); - expect(table['ICPCPrelim2023']['1671'].title).toBe('Planning Locations of Bus Stops'); + expect(table['ICPCPrelim2023']['B'].title).toBe('Amidakuji'); + expect(table['ICPCPrelim2023']['C'].title).toBe('Changing the Sitting Arrangement'); + expect(table['ICPCPrelim2023']['D'].title).toBe('Efficient Problem Set'); + expect(table['ICPCPrelim2023']['E'].title).toBe('Tampered Records'); + expect(table['ICPCPrelim2023']['F'].title).toBe('Villa of Emblem Shape'); + expect(table['ICPCPrelim2023']['G'].title).toBe('Fair Deal of Dice'); + expect(table['ICPCPrelim2023']['H'].title).toBe('Planning Locations of Bus Stops'); }); test('title is unchanged when generateTable is called twice (structurally idempotent)', () => { @@ -216,14 +215,14 @@ describe('AojIcpcPrelimProvider', () => { const secondInput = Object.values(firstTable['ICPCPrelim2023']) as TaskResults; const secondTable = provider2023.generateTable(secondInput); - expect(secondTable['ICPCPrelim2023']['1665'].title).toBe('Amidakuji'); + expect(secondTable['ICPCPrelim2023']['B'].title).toBe('Amidakuji'); }); 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']), + expect.arrayContaining(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']), ); }); @@ -307,25 +306,25 @@ describe('AojIcpcPrelimProvider', () => { describe('getHeaderIdsForTask', () => { describe('successful cases', () => { - test('returns indices sorted numerically ascending regardless of input order', () => { + test('returns letters in ascending order regardless of input order', () => { const reversedTasks = [...tasks2023].reverse() as TaskResults; expect(provider2023.getHeaderIdsForTask(reversedTasks)).toEqual([ - '1664', - '1665', - '1666', - '1667', - '1668', - '1669', - '1670', - '1671', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', ]); }); test('deduplicates repeated task_table_index values', () => { const duplicateTasks = [tasks2023[0], tasks2023[0]] as TaskResults; - expect(provider2023.getHeaderIdsForTask(duplicateTasks)).toEqual(['1664']); + expect(provider2023.getHeaderIdsForTask(duplicateTasks)).toEqual(['A']); }); }); @@ -348,12 +347,10 @@ describe('AojIcpcPrelimProvider', () => { test('oldest year 1998 stores raw titles for 4 tasks', () => { const table = provider1998.generateTable(tasks1998); - expect(table['ICPCPrelim1998']['1100'].title).toBe('Area of Polygons'); - expect(table['ICPCPrelim1998']['1101'].title).toBe('A Simple Offline Text Editor'); - expect(table['ICPCPrelim1998']['1102'].title).toBe('Calculation of Expressions'); - expect(table['ICPCPrelim1998']['1103'].title).toBe( - 'Board Arrangements for Concentration Games', - ); + expect(table['ICPCPrelim1998']['A'].title).toBe('Area of Polygons'); + expect(table['ICPCPrelim1998']['B'].title).toBe('A Simple Offline Text Editor'); + expect(table['ICPCPrelim1998']['C'].title).toBe('Calculation of Expressions'); + expect(table['ICPCPrelim1998']['D'].title).toBe('Board Arrangements for Concentration Games'); }); test('oldest year 1998 filter isolates its own contest_id', () => { @@ -372,8 +369,8 @@ describe('AojIcpcPrelimProvider', () => { test('latest year 2026 stores raw titles (maximum problem count)', () => { const table = provider2026.generateTable(tasks2026); - expect(table['ICPCPrelim2026']['1690'].title).toBe('Find the Strongest Card'); - expect(table['ICPCPrelim2026']['1699'].title).toBe('Maximum Scaling'); + expect(table['ICPCPrelim2026']['A'].title).toBe('Find the Strongest Card'); + expect(table['ICPCPrelim2026']['J'].title).toBe('Maximum Scaling'); }); test('latest year 2026 filter isolates its own contest_id', () => { @@ -384,105 +381,6 @@ describe('AojIcpcPrelimProvider', () => { expect(filtered.every((task) => task.contest_id === 'ICPCPrelim2026')).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(() => { - AOJ_LABEL_OVERRIDES[TEST_CONTEST_ID] = { - '9001': 'X', - '9002': 'Y', - }; - }); - - afterEach(() => { - delete AOJ_LABEL_OVERRIDES[TEST_CONTEST_ID]; - }); - - test('generateTable stores raw titles even when override map is active', () => { - const provider = createProvider(TEST_YEAR); - const table = provider.generateTable(overrideTasks); - - expect(table[TEST_CONTEST_ID]['9001'].title).toBe('Task One'); - expect(table[TEST_CONTEST_ID]['9002'].title).toBe('Task Two'); - }); - }); - - describe('getTaskLabels', () => { - describe('successful cases', () => { - test('returns letter map for all 8 tasks in numeric ID order (A–H)', () => { - const labels = provider2023.getTaskLabels(tasks2023); - - expect(labels['ICPCPrelim2023']['1664']).toBe('A'); - expect(labels['ICPCPrelim2023']['1665']).toBe('B'); - expect(labels['ICPCPrelim2023']['1666']).toBe('C'); - expect(labels['ICPCPrelim2023']['1667']).toBe('D'); - expect(labels['ICPCPrelim2023']['1668']).toBe('E'); - expect(labels['ICPCPrelim2023']['1669']).toBe('F'); - expect(labels['ICPCPrelim2023']['1670']).toBe('G'); - expect(labels['ICPCPrelim2023']['1671']).toBe('H'); - }); - - test('returns object keyed by contestId', () => { - const labels = provider2023.getTaskLabels(tasks2023); - - expect(Object.keys(labels)).toEqual(['ICPCPrelim2023']); - }); - }); - - describe('edge cases', () => { - test('returns empty inner object for empty input', () => { - const labels = provider2023.getTaskLabels([] as TaskResults); - - expect(labels).toEqual({ ICPCPrelim2023: {} }); - }); - }); - - 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(() => { - AOJ_LABEL_OVERRIDES[TEST_CONTEST_ID] = { - '9001': 'X', - '9002': 'Y', - }; - }); - - afterEach(() => { - delete AOJ_LABEL_OVERRIDES[TEST_CONTEST_ID]; - }); - - test('returns custom labels from AOJ_LABEL_OVERRIDES', () => { - const provider = createProvider(TEST_YEAR); - const labels = provider.getTaskLabels(overrideTasks); - - expect(labels[TEST_CONTEST_ID]['9001']).toBe('X'); - expect(labels[TEST_CONTEST_ID]['9002']).toBe('Y'); - }); - }); - }); }); const createRegionalProvider = (year: number) => @@ -493,49 +391,49 @@ const regionalTasks1998: TaskResults = [ { contest_id: 'ICPCRegional1998', task_id: '1200', - task_table_index: '1200', + task_table_index: 'A', title: "Goldbach's Conjecture", }, { contest_id: 'ICPCRegional1998', task_id: '1201', - task_table_index: '1201', + task_table_index: 'B', title: 'Lattice Practices', }, { contest_id: 'ICPCRegional1998', task_id: '1202', - task_table_index: '1202', + task_table_index: 'C', title: 'Mobile Phone Coverage', }, { contest_id: 'ICPCRegional1998', task_id: '1203', - task_table_index: '1203', + task_table_index: 'D', title: "Napoleon's Grumble", }, { contest_id: 'ICPCRegional1998', task_id: '1204', - task_table_index: '1204', + task_table_index: 'E', title: 'Pipeline Scheduling', }, { contest_id: 'ICPCRegional1998', task_id: '1205', - task_table_index: '1205', + task_table_index: 'F', title: 'Triangle Partition', }, { contest_id: 'ICPCRegional1998', task_id: '1206', - task_table_index: '1206', + task_table_index: 'G', title: 'BUT We Need a Diagram', }, { contest_id: 'ICPCRegional1998', task_id: '1207', - task_table_index: '1207', + task_table_index: 'H', title: 'Digital Racing Circuil', }, ] as TaskResults; @@ -545,73 +443,73 @@ const regionalTasks2024: TaskResults = [ { contest_id: 'ICPCRegional2024', task_id: '1455', - task_table_index: '1455', + task_table_index: 'A', title: 'Ribbon on the Christmas Present', }, { contest_id: 'ICPCRegional2024', task_id: '1456', - task_table_index: '1456', + task_table_index: 'B', title: 'The Sparsest Number in Between', }, { contest_id: 'ICPCRegional2024', task_id: '1457', - task_table_index: '1457', + task_table_index: 'C', title: 'Omnes Viae Yokohamam Ducunt?', }, { contest_id: 'ICPCRegional2024', task_id: '1458', - task_table_index: '1458', + task_table_index: 'D', title: 'Tree Generators', }, { contest_id: 'ICPCRegional2024', task_id: '1459', - task_table_index: '1459', + task_table_index: 'E', title: 'E-Circuit Is Now on Sale!', }, { contest_id: 'ICPCRegional2024', task_id: '1460', - task_table_index: '1460', + task_table_index: 'F', title: 'The Farthest Point', }, { contest_id: 'ICPCRegional2024', task_id: '1461', - task_table_index: '1461', + task_table_index: 'G', title: 'Beyond the Former Explorer', }, { contest_id: 'ICPCRegional2024', task_id: '1462', - task_table_index: '1462', + task_table_index: 'H', title: 'Remodeling the Dungeon 2', }, { contest_id: 'ICPCRegional2024', task_id: '1463', - task_table_index: '1463', + task_table_index: 'I', title: 'Greatest of the Greatest Common Divisors', }, { contest_id: 'ICPCRegional2024', task_id: '1464', - task_table_index: '1464', + task_table_index: 'J', title: 'Mixing Solutions', }, { contest_id: 'ICPCRegional2024', task_id: '1465', - task_table_index: '1465', + task_table_index: 'K', title: 'Scheduling Two Meetings', }, { contest_id: 'ICPCRegional2024', task_id: '1466', - task_table_index: '1466', + task_table_index: 'L', title: 'Peculiar Protocol', }, ] as TaskResults; @@ -622,7 +520,7 @@ const mixedRegionalTasks: TaskResults = [ { contest_id: 'ICPCRegional1999', task_id: '1208', - task_table_index: '1208', + task_table_index: 'A', title: 'Rational Irrationals', }, // Non-ICPC contest, to verify contest-type filtering @@ -672,8 +570,8 @@ describe('AojIcpcRegionalProvider', () => { test('stores raw titles (no letter prefix) for all 8 tasks', () => { const table = provider1998.generateTable(regionalTasks1998); - expect(table['ICPCRegional1998']['1200'].title).toBe("Goldbach's Conjecture"); - expect(table['ICPCRegional1998']['1207'].title).toBe('Digital Racing Circuil'); + expect(table['ICPCRegional1998']['A'].title).toBe("Goldbach's Conjecture"); + expect(table['ICPCRegional1998']['H'].title).toBe('Digital Racing Circuil'); }); test('title is unchanged when generateTable is called twice (structurally idempotent)', () => { @@ -681,14 +579,14 @@ describe('AojIcpcRegionalProvider', () => { const secondInput = Object.values(firstTable['ICPCRegional1998']) as TaskResults; const secondTable = provider1998.generateTable(secondInput); - expect(secondTable['ICPCRegional1998']['1201'].title).toBe('Lattice Practices'); + expect(secondTable['ICPCRegional1998']['B'].title).toBe('Lattice Practices'); }); test('uses task_table_index as the inner key', () => { const table = provider1998.generateTable(regionalTasks1998); expect(Object.keys(table['ICPCRegional1998'])).toEqual( - expect.arrayContaining(['1200', '1201', '1202', '1203', '1204', '1205', '1206', '1207']), + expect.arrayContaining(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']), ); }); @@ -772,25 +670,25 @@ describe('AojIcpcRegionalProvider', () => { describe('getHeaderIdsForTask', () => { describe('successful cases', () => { - test('returns indices sorted numerically ascending regardless of input order', () => { + test('returns letters in ascending order regardless of input order', () => { const reversedTasks = [...regionalTasks1998].reverse() as TaskResults; expect(provider1998.getHeaderIdsForTask(reversedTasks)).toEqual([ - '1200', - '1201', - '1202', - '1203', - '1204', - '1205', - '1206', - '1207', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', ]); }); test('deduplicates repeated task_table_index values', () => { const duplicateTasks = [regionalTasks1998[0], regionalTasks1998[0]] as TaskResults; - expect(provider1998.getHeaderIdsForTask(duplicateTasks)).toEqual(['1200']); + expect(provider1998.getHeaderIdsForTask(duplicateTasks)).toEqual(['A']); }); }); @@ -812,8 +710,8 @@ describe('AojIcpcRegionalProvider', () => { test('latest year 2024 stores raw titles (maximum problem count)', () => { const table = provider2024.generateTable(regionalTasks2024); - expect(table['ICPCRegional2024']['1455'].title).toBe('Ribbon on the Christmas Present'); - expect(table['ICPCRegional2024']['1466'].title).toBe('Peculiar Protocol'); + expect(table['ICPCRegional2024']['A'].title).toBe('Ribbon on the Christmas Present'); + expect(table['ICPCRegional2024']['L'].title).toBe('Peculiar Protocol'); }); test('latest year 2024 filter isolates its own contest_id', () => { @@ -824,79 +722,4 @@ describe('AojIcpcRegionalProvider', () => { expect(filtered.every((task) => task.contest_id === 'ICPCRegional2024')).toBe(true); }); }); - - describe('getTaskLabels', () => { - describe('successful cases', () => { - test('returns letter map for all 8 tasks in numeric ID order (A–H)', () => { - const labels = provider1998.getTaskLabels(regionalTasks1998); - - expect(labels['ICPCRegional1998']['1200']).toBe('A'); - expect(labels['ICPCRegional1998']['1201']).toBe('B'); - expect(labels['ICPCRegional1998']['1202']).toBe('C'); - expect(labels['ICPCRegional1998']['1203']).toBe('D'); - expect(labels['ICPCRegional1998']['1204']).toBe('E'); - expect(labels['ICPCRegional1998']['1205']).toBe('F'); - expect(labels['ICPCRegional1998']['1206']).toBe('G'); - expect(labels['ICPCRegional1998']['1207']).toBe('H'); - }); - - test('returns letter map A–L for 12 tasks (2024)', () => { - const provider2024 = createRegionalProvider(2024); - const labels = provider2024.getTaskLabels(regionalTasks2024); - - expect(labels['ICPCRegional2024']['1455']).toBe('A'); - expect(labels['ICPCRegional2024']['1466']).toBe('L'); - }); - - test('returns object keyed by contestId', () => { - const labels = provider1998.getTaskLabels(regionalTasks1998); - - expect(Object.keys(labels)).toEqual(['ICPCRegional1998']); - }); - }); - - describe('edge cases', () => { - test('returns empty inner object for empty input', () => { - const labels = provider1998.getTaskLabels([] as TaskResults); - - expect(labels).toEqual({ ICPCRegional1998: {} }); - }); - }); - - describe('override map path', () => { - const TEST_YEAR = 8888; - const TEST_CONTEST_ID = `ICPCRegional${TEST_YEAR}`; - - const overrideTasks: TaskResults = [ - { - contest_id: TEST_CONTEST_ID, - task_id: '8001', - task_table_index: '8001', - title: 'Task One', - }, - { - contest_id: TEST_CONTEST_ID, - task_id: '8002', - task_table_index: '8002', - title: 'Task Two', - }, - ] as TaskResults; - - beforeEach(() => { - AOJ_LABEL_OVERRIDES[TEST_CONTEST_ID] = { '8001': 'X', '8002': 'Y' }; - }); - - afterEach(() => { - delete AOJ_LABEL_OVERRIDES[TEST_CONTEST_ID]; - }); - - test('returns custom labels from AOJ_LABEL_OVERRIDES', () => { - const provider = createRegionalProvider(TEST_YEAR); - const labels = provider.getTaskLabels(overrideTasks); - - expect(labels[TEST_CONTEST_ID]['8001']).toBe('X'); - expect(labels[TEST_CONTEST_ID]['8002']).toBe('Y'); - }); - }); - }); }); 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 047ff48c4..1d5b27466 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts @@ -6,12 +6,7 @@ import { } from '#features/tasks/types/contest-table/contest_table_provider.js'; import { ContestTableProviderBase } from './contest_table_provider_base'; -import { - buildAojLetterMap, - sortAojHeaderIds, - AOJ_TITLE_STYLE, - buildAojDisplayConfig, -} from './aoj_labels'; +import { sortAojHeaderIds, AOJ_TITLE_STYLE, buildAojDisplayConfig } from './aoj_labels'; export class AojIcpcPrelimProvider extends ContestTableProviderBase { private readonly year: number; @@ -46,15 +41,6 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase { getContestRoundLabel(_contestId: string): string { return `ICPC 国内予選 ${this.year}`; } - - override getTaskLabels(filtered: TaskResults): Record> { - const letterMap = buildAojLetterMap( - this.contestId, - filtered.map((taskResult) => taskResult.task_table_index), - ); - - return { [this.contestId]: Object.fromEntries(letterMap) }; - } } export class AojIcpcRegionalProvider extends ContestTableProviderBase { @@ -90,13 +76,4 @@ export class AojIcpcRegionalProvider extends ContestTableProviderBase { getContestRoundLabel(_contestId: string): string { return `ICPC アジア地区 ${this.year}`; } - - override getTaskLabels(filtered: TaskResults): Record> { - const letterMap = buildAojLetterMap( - this.contestId, - filtered.map((taskResult) => taskResult.task_table_index), - ); - - return { [this.contestId]: Object.fromEntries(letterMap) }; - } } diff --git a/src/features/tasks/utils/contest-table/aoj_jag_providers.test.ts b/src/features/tasks/utils/contest-table/aoj_jag_providers.test.ts index e8b2f6fcd..3f29971cc 100644 --- a/src/features/tasks/utils/contest-table/aoj_jag_providers.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_jag_providers.test.ts @@ -13,37 +13,37 @@ const tasks2005: TaskResults = [ { contest_id: 'JAGPrelim2005', task_id: '2006', - task_table_index: '2006', + task_table_index: 'A', title: 'Keitai Message', }, { contest_id: 'JAGPrelim2005', task_id: '2007', - task_table_index: '2007', + task_table_index: 'B', title: 'Make Purse Light', }, { contest_id: 'JAGPrelim2005', task_id: '2008', - task_table_index: '2008', + task_table_index: 'C', title: 'Dragon Fantasy', }, { contest_id: 'JAGPrelim2005', task_id: '2009', - task_table_index: '2009', + task_table_index: 'D', title: 'Area Separation', }, { contest_id: 'JAGPrelim2005', task_id: '2010', - task_table_index: '2010', + task_table_index: 'E', title: 'Poor Mail Forwarding', }, { contest_id: 'JAGPrelim2005', task_id: '2011', - task_table_index: '2011', + task_table_index: 'F', title: 'Gather the Maps!', }, ] as TaskResults; @@ -53,77 +53,77 @@ const tasks2023: TaskResults = [ { contest_id: 'JAGPrelim2023', task_id: '3358', - task_table_index: '3358', + task_table_index: 'A', title: 'Sorting Crayons', }, { contest_id: 'JAGPrelim2023', task_id: '3359', - task_table_index: '3359', + task_table_index: 'B', title: 'Everyday is Holiday', }, - { contest_id: 'JAGPrelim2023', task_id: '3360', task_table_index: '3360', title: 'Game' }, - { contest_id: 'JAGPrelim2023', task_id: '3361', task_table_index: '3361', title: 'IPvK' }, + { contest_id: 'JAGPrelim2023', task_id: '3360', task_table_index: 'C', title: 'Game' }, + { contest_id: 'JAGPrelim2023', task_id: '3361', task_table_index: 'D', title: 'IPvK' }, { contest_id: 'JAGPrelim2023', task_id: '3362', - task_table_index: '3362', + task_table_index: 'E', title: 'Watch the Marathon', }, { contest_id: 'JAGPrelim2023', task_id: '3363', - task_table_index: '3363', + task_table_index: 'F', title: 'Frog in a Well', }, { contest_id: 'JAGPrelim2023', task_id: '3364', - task_table_index: '3364', + task_table_index: 'G', title: 'Watch the Movie', }, - { contest_id: 'JAGPrelim2023', task_id: '3365', task_table_index: '3365', title: 'Orienteering' }, + { contest_id: 'JAGPrelim2023', task_id: '3365', task_table_index: 'H', title: 'Orienteering' }, ] as TaskResults; // JAGPrelim2026: 9 problems (A–I), latest year and maximum problem count. const tasks2026: TaskResults = [ - { contest_id: 'JAGPrelim2026', task_id: '3452', task_table_index: '3452', title: 'Booby Prize' }, - { contest_id: 'JAGPrelim2026', task_id: '3453', task_table_index: '3453', title: 'Rearrange' }, + { contest_id: 'JAGPrelim2026', task_id: '3452', task_table_index: 'A', title: 'Booby Prize' }, + { contest_id: 'JAGPrelim2026', task_id: '3453', task_table_index: 'B', title: 'Rearrange' }, { contest_id: 'JAGPrelim2026', task_id: '3454', - task_table_index: '3454', + task_table_index: 'C', title: 'Loooong Vacation', }, - { contest_id: 'JAGPrelim2026', task_id: '3455', task_table_index: '3455', title: 'Box Tower' }, + { contest_id: 'JAGPrelim2026', task_id: '3455', task_table_index: 'D', title: 'Box Tower' }, { contest_id: 'JAGPrelim2026', task_id: '3456', - task_table_index: '3456', + task_table_index: 'E', title: 'Secret Numbers and the Greatest Common Divisor', }, { contest_id: 'JAGPrelim2026', task_id: '3457', - task_table_index: '3457', + task_table_index: 'F', title: 'ICPC is a Contest', }, { contest_id: 'JAGPrelim2026', task_id: '3458', - task_table_index: '3458', + task_table_index: 'G', title: 'XOR Travel Plans', }, { contest_id: 'JAGPrelim2026', task_id: '3459', - task_table_index: '3459', + task_table_index: 'H', title: 'Stone-Picking Game', }, { contest_id: 'JAGPrelim2026', task_id: '3460', - task_table_index: '3460', + task_table_index: 'I', title: "Let's Build an Evil Robot Army", }, ] as TaskResults; @@ -133,33 +133,33 @@ const tasks2016A: TaskResults = [ { contest_id: 'JAGPrelim2016A', task_id: '2738', - task_table_index: '2738', + task_table_index: 'A', title: 'A-un Breathing', }, { contest_id: 'JAGPrelim2016A', task_id: '2739', - task_table_index: '2739', + task_table_index: 'B', title: 'Delivery to a Luxurious House', }, { contest_id: 'JAGPrelim2016A', task_id: '2740', - task_table_index: '2740', + task_table_index: 'C', title: 'Rooted Tree for Misawa-san', }, - { contest_id: 'JAGPrelim2016A', task_id: '2741', task_table_index: '2741', title: 'Invisible' }, - { contest_id: 'JAGPrelim2016A', task_id: '2742', task_table_index: '2742', title: 'Campaign' }, + { contest_id: 'JAGPrelim2016A', task_id: '2741', task_table_index: 'D', title: 'Invisible' }, + { contest_id: 'JAGPrelim2016A', task_id: '2742', task_table_index: 'E', title: 'Campaign' }, { contest_id: 'JAGPrelim2016A', task_id: '2743', - task_table_index: '2743', + task_table_index: 'F', title: 'Land Inheritance', }, { contest_id: 'JAGPrelim2016A', task_id: '2744', - task_table_index: '2744', + task_table_index: 'G', title: 'Rings and Strings', }, ] as TaskResults; @@ -168,30 +168,30 @@ const tasks2016B: TaskResults = [ { contest_id: 'JAGPrelim2016B', task_id: '2745', - task_table_index: '2745', + task_table_index: 'A', title: 'Curry Making', }, - { contest_id: 'JAGPrelim2016B', task_id: '2746', task_table_index: '2746', title: 'jfen' }, - { contest_id: 'JAGPrelim2016B', task_id: '2747', task_table_index: '2747', title: 'Curtain' }, + { contest_id: 'JAGPrelim2016B', task_id: '2746', task_table_index: 'B', title: 'jfen' }, + { contest_id: 'JAGPrelim2016B', task_id: '2747', task_table_index: 'C', title: 'Curtain' }, { contest_id: 'JAGPrelim2016B', task_id: '2748', - task_table_index: '2748', + task_table_index: 'D', title: 'Early Morning Work at Summer Camp', }, { contest_id: 'JAGPrelim2016B', task_id: '2749', - task_table_index: '2749', + task_table_index: 'E', title: 'The Most Powerful Bed', }, { contest_id: 'JAGPrelim2016B', task_id: '2750', - task_table_index: '2750', + task_table_index: 'F', title: 'Hyakunin Isshu', }, - { contest_id: 'JAGPrelim2016B', task_id: '2751', task_table_index: '2751', title: 'Baseball' }, + { contest_id: 'JAGPrelim2016B', task_id: '2751', task_table_index: 'G', title: 'Baseball' }, ] as TaskResults; describe('JagPrelimProvider', () => { @@ -243,8 +243,8 @@ describe('JagPrelimProvider', () => { test('stores raw titles (no letter prefix) for all 8 tasks', () => { const table = provider2023.generateTable(tasks2023); - expect(table['JAGPrelim2023']['3358'].title).toBe('Sorting Crayons'); - expect(table['JAGPrelim2023']['3365'].title).toBe('Orienteering'); + expect(table['JAGPrelim2023']['A'].title).toBe('Sorting Crayons'); + expect(table['JAGPrelim2023']['H'].title).toBe('Orienteering'); }); test('title is unchanged when generateTable is called twice (structurally idempotent)', () => { @@ -252,7 +252,7 @@ describe('JagPrelimProvider', () => { const secondInput = Object.values(firstTable['JAGPrelim2023']) as TaskResults; const secondTable = provider2023.generateTable(secondInput); - expect(secondTable['JAGPrelim2023']['3358'].title).toBe('Sorting Crayons'); + expect(secondTable['JAGPrelim2023']['A'].title).toBe('Sorting Crayons'); }); test('creates table keyed by contest_id', () => { @@ -311,25 +311,25 @@ describe('JagPrelimProvider', () => { describe('getHeaderIdsForTask', () => { describe('successful cases', () => { - test('returns indices sorted numerically ascending regardless of input order', () => { + test('returns letters in ascending order regardless of input order', () => { const reversedTasks = [...tasks2023].reverse() as TaskResults; expect(provider2023.getHeaderIdsForTask(reversedTasks)).toEqual([ - '3358', - '3359', - '3360', - '3361', - '3362', - '3363', - '3364', - '3365', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', ]); }); test('deduplicates repeated task_table_index values', () => { const duplicateTasks = [tasks2023[0], tasks2023[0]] as TaskResults; - expect(provider2023.getHeaderIdsForTask(duplicateTasks)).toEqual(['3358']); + expect(provider2023.getHeaderIdsForTask(duplicateTasks)).toEqual(['A']); }); }); @@ -340,32 +340,6 @@ describe('JagPrelimProvider', () => { }); }); - describe('getTaskLabels', () => { - describe('successful cases', () => { - test('returns letter map for all 8 tasks in numeric ID order (A–H)', () => { - const labels = provider2023.getTaskLabels(tasks2023); - - expect(labels['JAGPrelim2023']['3358']).toBe('A'); - expect(labels['JAGPrelim2023']['3359']).toBe('B'); - expect(labels['JAGPrelim2023']['3365']).toBe('H'); - }); - - test('returns object keyed by contestId', () => { - const labels = provider2023.getTaskLabels(tasks2023); - - expect(Object.keys(labels)).toEqual(['JAGPrelim2023']); - }); - }); - - describe('edge cases', () => { - test('returns empty inner object for empty input', () => { - const labels = provider2023.getTaskLabels([] as TaskResults); - - expect(labels).toEqual({ JAGPrelim2023: {} }); - }); - }); - }); - describe('year boundary behavior', () => { const provider2005 = createProvider(2005); const provider2026 = createProvider(2026); @@ -391,8 +365,8 @@ describe('JagPrelimProvider', () => { test('latest year 2026 stores raw titles (maximum problem count)', () => { const table = provider2026.generateTable(tasks2026); - expect(table['JAGPrelim2026']['3452'].title).toBe('Booby Prize'); - expect(table['JAGPrelim2026']['3460'].title).toBe("Let's Build an Evil Robot Army"); + expect(table['JAGPrelim2026']['A'].title).toBe('Booby Prize'); + expect(table['JAGPrelim2026']['I'].title).toBe("Let's Build an Evil Robot Army"); }); }); @@ -438,18 +412,14 @@ describe('JagPrelimProvider', () => { expect(filtered.every((task) => task.contest_id === 'JAGPrelim2016B')).toBe(true); }); - test('2016 A labels A–G keyed by JAGPrelim2016A', () => { - const labels = provider2016A.getTaskLabels(tasks2016A); - - expect(labels['JAGPrelim2016A']['2738']).toBe('A'); - expect(labels['JAGPrelim2016A']['2744']).toBe('G'); - }); - - test('2016 B labels A–G keyed by JAGPrelim2016B (letters restart from A)', () => { - const labels = provider2016B.getTaskLabels(tasks2016B); + test('2016 A and B tables are each keyed by letters A–G', () => { + const tableA = provider2016A.generateTable(tasks2016A); + const tableB = provider2016B.generateTable(tasks2016B); - expect(labels['JAGPrelim2016B']['2745']).toBe('A'); - expect(labels['JAGPrelim2016B']['2751']).toBe('G'); + expect(tableA['JAGPrelim2016A']['A'].title).toBe('A-un Breathing'); + expect(tableA['JAGPrelim2016A']['G'].title).toBe('Rings and Strings'); + expect(tableB['JAGPrelim2016B']['A'].title).toBe('Curry Making'); + expect(tableB['JAGPrelim2016B']['G'].title).toBe('Baseball'); }); }); }); diff --git a/src/features/tasks/utils/contest-table/aoj_jag_providers.ts b/src/features/tasks/utils/contest-table/aoj_jag_providers.ts index 851452124..703948708 100644 --- a/src/features/tasks/utils/contest-table/aoj_jag_providers.ts +++ b/src/features/tasks/utils/contest-table/aoj_jag_providers.ts @@ -6,12 +6,7 @@ import { } from '#features/tasks/types/contest-table/contest_table_provider.js'; import { ContestTableProviderBase } from './contest_table_provider_base'; -import { - buildAojLetterMap, - sortAojHeaderIds, - AOJ_TITLE_STYLE, - buildAojDisplayConfig, -} from './aoj_labels'; +import { sortAojHeaderIds, AOJ_TITLE_STYLE, buildAojDisplayConfig } from './aoj_labels'; /** * One JAG Domestic Preliminary (JAG 模擬国内) table, keyed by year. @@ -56,15 +51,6 @@ export class JagPrelimProvider extends ContestTableProviderBase { return this.buildTitle(); } - override getTaskLabels(filtered: TaskResults): Record> { - const letterMap = buildAojLetterMap( - this.contestId, - filtered.map((taskResult) => taskResult.task_table_index), - ); - - return { [this.contestId]: Object.fromEntries(letterMap) }; - } - private buildTitle(): string { return this.suffix ? `JAG 模擬国内 ${this.year} ${this.suffix}` : `JAG 模擬国内 ${this.year}`; } diff --git a/src/features/tasks/utils/contest-table/aoj_labels.test.ts b/src/features/tasks/utils/contest-table/aoj_labels.test.ts index a0719fafd..2cc92a66c 100644 --- a/src/features/tasks/utils/contest-table/aoj_labels.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_labels.test.ts @@ -1,170 +1,45 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { describe, test, expect } from 'vitest'; import type { TaskResults } from '#lib/types/task.js'; -import { - buildAojLetterMap, - buildAojDisplayConfig, - formatAojTitle, - sortAojHeaderIds, - AOJ_LABEL_OVERRIDES, -} from './aoj_labels'; - -describe('formatAojTitle', () => { - test('prepends the letter and a dot to the title (Prelim)', () => { - expect(formatAojTitle('Amidakuji', 'B')).toBe('B. Amidakuji'); - }); - - test('prepends the letter and a dot to the title (Regional)', () => { - expect(formatAojTitle('Yokohama Phenomena', 'A')).toBe('A. Yokohama Phenomena'); - }); -}); - -describe('buildAojLetterMap', () => { - describe('Prelim', () => { - test('sorts indices numerically ascending and assigns letters A, B, C...', () => { - const result = buildAojLetterMap('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 = buildAojLetterMap('ICPCPrelim2023', []); - - expect(result.size).toBe(0); - }); - - test('returns Map with single entry assigned letter A', () => { - const result = buildAojLetterMap('ICPCPrelim2023', ['1000']); - - expect(result.get('1000')).toBe('A'); - expect(result.size).toBe(1); - }); - - describe('override path', () => { - beforeEach(() => { - AOJ_LABEL_OVERRIDES['ICPCPrelimTest'] = { - '1150': 'A', - '1152': 'C', - '1155': 'E', - }; - }); - - afterEach(() => { - delete AOJ_LABEL_OVERRIDES['ICPCPrelimTest']; - }); - - test('uses override map when contest_id has an entry in AOJ_LABEL_OVERRIDES', () => { - const result = buildAojLetterMap('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 = buildAojLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); - - expect(result.has('1151')).toBe(false); - expect(result.has('1153')).toBe(false); - }); - }); - }); - - describe('Regional', () => { - test('sorts indices numerically ascending and assigns letters A, B, C...', () => { - const result = buildAojLetterMap('ICPCRegional2023', ['1445', '1444']); - - expect(result.get('1444')).toBe('A'); - expect(result.get('1445')).toBe('B'); - expect(result.size).toBe(2); - }); - - test('assigns letters A through L for the 12 problems of 2024', () => { - const indices = [ - '1455', - '1456', - '1457', - '1458', - '1459', - '1460', - '1461', - '1462', - '1463', - '1464', - '1465', - '1466', - ]; - const result = buildAojLetterMap('ICPCRegional2024', indices); - - expect(result.get('1455')).toBe('A'); - expect(result.get('1466')).toBe('L'); - expect(result.size).toBe(12); - }); - - test('returns empty Map for empty input', () => { - const result = buildAojLetterMap('ICPCRegional2023', []); - - expect(result.size).toBe(0); - }); - - test('returns Map with single entry assigned letter A', () => { - const result = buildAojLetterMap('ICPCRegional2023', ['1444']); - - expect(result.get('1444')).toBe('A'); - expect(result.size).toBe(1); - }); - - describe('override path', () => { - beforeEach(() => { - AOJ_LABEL_OVERRIDES['ICPCRegionalTest'] = { - '1444': 'A', - '1446': 'C', - '1448': 'E', - }; - }); - - afterEach(() => { - delete AOJ_LABEL_OVERRIDES['ICPCRegionalTest']; - }); - - test('uses override map when contest_id has an entry in AOJ_LABEL_OVERRIDES', () => { - const result = buildAojLetterMap('ICPCRegionalTest', ['1444', '1446', '1448']); - - expect(result.get('1444')).toBe('A'); - expect(result.get('1446')).toBe('C'); - expect(result.get('1448')).toBe('E'); - expect(result.size).toBe(3); - }); - - test('ignores the input indices and uses only the override map entries', () => { - const result = buildAojLetterMap('ICPCRegionalTest', ['1444', '1446', '1448']); - - expect(result.has('1445')).toBe(false); - expect(result.has('1447')).toBe(false); - }); - }); - }); -}); +import { buildAojDisplayConfig, sortAojHeaderIds } from './aoj_labels'; describe('sortAojHeaderIds', () => { - test('returns unique task_table_index values sorted numerically ascending', () => { + test('returns unique task_table_index values in ascending letter order', () => { const filtered = [ - { task_table_index: '1666' }, - { task_table_index: '1664' }, - { task_table_index: '1665' }, + { task_table_index: 'C' }, + { task_table_index: 'A' }, + { task_table_index: 'B' }, ] as TaskResults; - expect(sortAojHeaderIds(filtered)).toEqual(['1664', '1665', '1666']); + expect(sortAojHeaderIds(filtered)).toEqual(['A', 'B', 'C']); + }); + + test('sorts letters A through L for the 12 problems of ICPC Regional 2024', () => { + const filtered = ['L', 'K', 'J', 'I', 'H', 'G', 'F', 'E', 'D', 'C', 'B', 'A'].map((letter) => ({ + task_table_index: letter, + })) as TaskResults; + + expect(sortAojHeaderIds(filtered)).toEqual([ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + ]); }); test('deduplicates repeated task_table_index values', () => { - const filtered = [{ task_table_index: '1664' }, { task_table_index: '1664' }] as TaskResults; + const filtered = [{ task_table_index: 'A' }, { task_table_index: 'A' }] as TaskResults; - expect(sortAojHeaderIds(filtered)).toEqual(['1664']); + expect(sortAojHeaderIds(filtered)).toEqual(['A']); }); test('returns empty array for empty input', () => { diff --git a/src/features/tasks/utils/contest-table/aoj_labels.ts b/src/features/tasks/utils/contest-table/aoj_labels.ts index 2a1d6e7f5..cfe49a56b 100644 --- a/src/features/tasks/utils/contest-table/aoj_labels.ts +++ b/src/features/tasks/utils/contest-table/aoj_labels.ts @@ -4,10 +4,6 @@ import type { ContestTableTitleStyle, } from '#features/tasks/types/contest-table/contest_table_provider.js'; -// contest_id -> (task_table_index -> letter). Used only for years with judge gaps. -// Keyed by full contest_id so AOJ contests (ICPC, JAG) can share one map. -export const AOJ_LABEL_OVERRIDES: Record> = {}; - export const AOJ_TITLE_STYLE: ContestTableTitleStyle = { headingTag: 'h2', fontSize: 'text-xl', @@ -15,15 +11,10 @@ export const AOJ_TITLE_STYLE: ContestTableTitleStyle = { bottomGap: 'pb-1', }; -// Prepend the assigned positional letter to an AOJ title for inline display (e.g. "A. name"). -export function formatAojTitle(title: string, letter: string): string { - return `${letter}. ${title}`; -} - -// Return unique task_table_index values sorted numerically ascending. +// Return unique task_table_index values (problem letters) in ascending order. export function sortAojHeaderIds(filtered: TaskResults): string[] { return Array.from(new Set(filtered.map((taskResult) => taskResult.task_table_index))).sort( - (first, second) => Number(first) - Number(second), + (first, second) => first.localeCompare(second), ); } @@ -37,26 +28,3 @@ export function buildAojDisplayConfig(): ContestTableDisplayConfig { columnWrapThreshold: 6, }; } - -// Build task_table_index -> letter map for one contest. -// Default: sort indices numerically asc, assign A, B, C... -// Override: if AOJ_LABEL_OVERRIDES[contestId] exists, use it. -export function buildAojLetterMap( - contestId: string, - taskTableIndices: string[], -): Map { - const override = AOJ_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/contest_table_provider_base.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts index a24770c29..b1129f88a 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts @@ -24,19 +24,6 @@ describe('ContestTableProviderBase', () => { expect(headerIds).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']); }); - test('getTaskLabels returns empty object by default', () => { - const provider = new ABSProvider(ContestType.ABS); - - expect(provider.getTaskLabels([])).toEqual({}); - }); - - test('getTaskLabels returns empty object regardless of input', () => { - const provider = new ABSProvider(ContestType.ABS); - const nonEmpty = taskResultsForABS.filter((task) => task.contest_id === 'abs'); - - expect(provider.getTaskLabels(nonEmpty)).toEqual({}); - }); - test('columnWrapThreshold is undefined by default (component falls back to 8)', () => { const provider = new ABSProvider(ContestType.ABS); diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_base.ts b/src/features/tasks/utils/contest-table/contest_table_provider_base.ts index 18cd16165..afeff2b8a 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_base.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_base.ts @@ -111,10 +111,6 @@ export abstract class ContestTableProviderBase implements ContestTableProvider { } abstract getContestRoundLabel(contestId: string): string; - - getTaskLabels(_filteredTaskResults: TaskResults): Record> { - return {}; - } } export function parseContestRound(contestId: string, prefix: string): number { diff --git a/src/lib/clients/aizu_online_judge/clients.test.ts b/src/lib/clients/aizu_online_judge/clients.test.ts index 201522c5d..0ba4458ab 100644 --- a/src/lib/clients/aizu_online_judge/clients.test.ts +++ b/src/lib/clients/aizu_online_judge/clients.test.ts @@ -33,6 +33,18 @@ function buildChallengesClient(): AojChallengesApiClient { return new AojChallengesApiClient(httpClient, buildCache()); } +// task_id -> problem_index for the tasks of one contest. +function getProblemIndicesByTaskId( + tasks: TasksForImport, + contestId: string, +): Record { + return Object.fromEntries( + tasks + .filter((task) => task.contest_id === contestId) + .map((task) => [task.task_id, task.problem_index]), + ); +} + const FIXTURE_PATHS = { courses: { contests: './src/lib/clients/fixtures/aizu_online_judge/courses/contests.json', @@ -176,6 +188,12 @@ describe('AojChallengesApiClient', () => { expect(tasks.length).toBe(expectedCount); }); + test('keeps the problem id as problem_index for PCK PRELIM tasks', async () => { + const tasks = await client.getTasks({ contestType: 'PCK', round: 'PRELIM' }); + + expect(tasks.every((task) => task.problem_index === task.task_id)).toBe(true); + }); + test('each PCK PRELIM task has required fields', async () => { const tasks = await client.getTasks({ contestType: 'PCK', round: 'PRELIM' }); tasks.forEach((task) => { @@ -232,6 +250,32 @@ describe('AojChallengesApiClient', () => { .flatMap((day) => day.problems).length; expect(tasks.length).toBe(expectedCount); }); + + test('assigns problem letters per contest in ascending problem id order', async () => { + const tasks = await client.getTasks({ contestType: 'JAG', round: 'PRELIM' }); + + expect(getProblemIndicesByTaskId(tasks, 'JAGPrelim2023')).toEqual({ + '3358': 'A', + '3359': 'B', + '3360': 'C', + '3361': 'D', + '3362': 'E', + '3363': 'F', + '3364': 'G', + '3365': 'H', + }); + }); + + // contest_id comes from contest.abbr, so letters must be unique across all days of a contest. + test('assigns letters across all days of a multi-day contest', async () => { + const tasks = await client.getTasks({ contestType: 'JAG', round: 'PRELIM' }); + const indices = getProblemIndicesByTaskId(tasks, 'JAGPrelim20162'); + + expect(indices['2738']).toBe('A'); + expect(indices['2744']).toBe('G'); + expect(indices['2745']).toBe('H'); + expect(indices['2751']).toBe('N'); + }); }); describe('JAG REGIONAL', () => { @@ -256,6 +300,19 @@ describe('AojChallengesApiClient', () => { .flatMap((day) => day.problems).length; expect(tasks.length).toBe(expectedCount); }); + + test('keeps the problem id as problem_index for JAG REGIONAL tasks', async () => { + const tasks = await client.getTasks({ contestType: 'JAG', round: 'REGIONAL' }); + + expect(getProblemIndicesByTaskId(tasks, 'JAGRegional2005')).toEqual({ + '2024': '2024', + '2025': '2025', + '2026': '2026', + '2027': '2027', + '2028': '2028', + '2029': '2029', + }); + }); }); describe('ICPC PRELIM', () => { @@ -280,6 +337,28 @@ describe('AojChallengesApiClient', () => { .flatMap((day) => day.problems).length; expect(tasks.length).toBe(expectedCount); }); + + test('assigns problem letters per contest in ascending problem id order', async () => { + const tasks = await client.getTasks({ contestType: 'ICPC', round: 'PRELIM' }); + + expect(getProblemIndicesByTaskId(tasks, 'ICPCPrelim1998')).toEqual({ + '1100': 'A', + '1101': 'B', + '1102': 'C', + '1103': 'D', + }); + expect(getProblemIndicesByTaskId(tasks, 'ICPCPrelim2025')).toEqual({ + '1681': 'A', + '1682': 'B', + '1683': 'C', + '1684': 'D', + '1685': 'E', + '1686': 'F', + '1687': 'G', + '1688': 'H', + '1689': 'I', + }); + }); }); describe('ICPC REGIONAL', () => { @@ -305,6 +384,16 @@ describe('AojChallengesApiClient', () => { expect(tasks.length).toBe(expectedCount); }); + test('assigns problem letters A through L for ICPC Regional 2024', async () => { + const tasks = await client.getTasks({ contestType: 'ICPC', round: 'REGIONAL' }); + const indices = getProblemIndicesByTaskId(tasks, 'ICPCRegional2024'); + + expect(Object.keys(indices)).toHaveLength(12); + expect(indices['1455']).toBe('A'); + expect(indices['1460']).toBe('F'); + expect(indices['1466']).toBe('L'); + }); + test('each ICPC REGIONAL task has required fields', async () => { const tasks = await client.getTasks({ contestType: 'ICPC', round: 'REGIONAL' }); tasks.forEach((task) => { diff --git a/src/lib/clients/aizu_online_judge/clients.ts b/src/lib/clients/aizu_online_judge/clients.ts index 0dacee80f..0ab8ba119 100644 --- a/src/lib/clients/aizu_online_judge/clients.ts +++ b/src/lib/clients/aizu_online_judge/clients.ts @@ -17,7 +17,13 @@ import type { ChallengeContest, ChallengeParams, } from './types'; -import { buildEndpoint, mapToContest, mapToTask, getCourseName } from './utils'; +import { + buildEndpoint, + mapToContest, + assignProblemLetters, + mapToTask, + getCourseName, +} from './utils'; export class AojCoursesApiClient { constructor( @@ -93,15 +99,29 @@ export class AojChallengesApiClient { validateResponse: (data) => 'contests' in data && Array.isArray(data.contests) && data.contests.length > 0, transformer: (data) => - data.contests.flatMap((contest: ChallengeContest) => - contest.days.flatMap((day) => - day.problems.map((problem) => mapToTask(problem, contest.abbr)), - ), - ), + data.contests.flatMap((contest: ChallengeContest) => { + const problems = contest.days.flatMap((day) => day.problems); + + if (!this.usesProblemLetters(params)) { + return problems.map((problem) => mapToTask(problem, contest.abbr)); + } + + // Letters span all days because contest_id is per contest (abbr), not per day. + const letters = assignProblemLetters(problems.map((problem) => problem.id)); + + return problems.map((problem) => + mapToTask(problem, contest.abbr, letters.get(problem.id)), + ); + }), label: `AOJ ${contestType} ${round}`, }); } + // Scope of the problem-letter migration; JAG Regional and PCK keep the problem id for now. + private usesProblemLetters({ contestType, round }: ChallengeParams): boolean { + return contestType === 'ICPC' || (contestType === 'JAG' && round === 'PRELIM'); + } + private getCacheKey({ contestType, round }: ChallengeParams): string { return `aoj_${contestType.toLowerCase()}_${round.toLowerCase()}`; } diff --git a/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts b/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts new file mode 100644 index 000000000..13dab6bd0 --- /dev/null +++ b/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'vitest'; + +import { ContestType } from '#lib/contests/types/contest.js'; +import { classifyContest } from '#lib/contests/utils/classification.js'; + +import { tasks } from '../../../../prisma/tasks'; + +import { assignProblemLetters } from './utils'; + +// Same scope as the problem-letter migration; JAG training camps are AOJ_JAG but keep problem ids. +function usesProblemLetters(contestId: string): boolean { + const contestType = classifyContest(contestId); + + return ( + contestType === ContestType.AOJ_ICPC || + (contestType === ContestType.AOJ_JAG && contestId.startsWith('JAGPrelim')) + ); +} + +function groupProblemIndicesByContest(): Map> { + const indicesByContest = new Map>(); + + for (const task of tasks) { + if (!usesProblemLetters(task.contest_id)) { + continue; + } + + const indices = indicesByContest.get(task.contest_id) ?? new Map(); + indices.set(task.id, task.problem_index); + indicesByContest.set(task.contest_id, indices); + } + + return indicesByContest; +} + +describe('problem_index of ICPC / JAG Prelim tasks in prisma/tasks.ts', () => { + const indicesByContest = groupProblemIndicesByContest(); + + // Guards against the filter silently matching fewer tasks. + test('covers 79 contests and 632 tasks', () => { + const taskCount = [...indicesByContest.values()].reduce( + (sum, indices) => sum + indices.size, + 0, + ); + + expect(indicesByContest.size).toBe(79); + expect(taskCount).toBe(632); + }); + + test.each([...indicesByContest])( + '%s matches the letters assigned on import', + (_contestId, indices) => { + expect(indices).toEqual(assignProblemLetters([...indices.keys()])); + }, + ); + + test('ICPC Prelim 2024 F is problem 1677', () => { + expect(indicesByContest.get('ICPCPrelim2024')?.get('1677')).toBe('F'); + }); +}); diff --git a/src/lib/clients/aizu_online_judge/utils.test.ts b/src/lib/clients/aizu_online_judge/utils.test.ts index 1d818a411..62aa57943 100644 --- a/src/lib/clients/aizu_online_judge/utils.test.ts +++ b/src/lib/clients/aizu_online_judge/utils.test.ts @@ -1,6 +1,12 @@ import { describe, test, expect } from 'vitest'; -import { buildEndpoint, mapToContest, mapToTask, getCourseName } from './utils'; +import { + buildEndpoint, + mapToContest, + assignProblemLetters, + mapToTask, + getCourseName, +} from './utils'; import { PENDING } from './types'; import type { AOJTaskAPI } from './types'; @@ -77,6 +83,55 @@ describe('mapToContest', () => { }); }); +describe('assignProblemLetters', () => { + describe('successful cases', () => { + test('assigns letters A, B, C... in ascending problem id order', () => { + const result = assignProblemLetters(['1665', '1664', '1666']); + + expect(result).toEqual( + new Map([ + ['1664', 'A'], + ['1665', 'B'], + ['1666', 'C'], + ]), + ); + }); + + test('assigns letters A through L to the 12 problems of ICPC Regional 2024', () => { + const problemIds = Array.from({ length: 12 }, (_, i) => String(1455 + i)); + const result = assignProblemLetters(problemIds); + + expect(result.get('1455')).toBe('A'); + expect(result.get('1466')).toBe('L'); + expect(result.size).toBe(12); + }); + + test('sorts problem ids numerically, not lexicographically', () => { + const result = assignProblemLetters(['10000', '9999']); + + expect(result.get('9999')).toBe('A'); + expect(result.get('10000')).toBe('B'); + }); + + test('does not mutate the input array', () => { + const problemIds = ['1665', '1664']; + assignProblemLetters(problemIds); + + expect(problemIds).toEqual(['1665', '1664']); + }); + }); + + describe('edge cases', () => { + test('returns an empty map for empty input', () => { + expect(assignProblemLetters([]).size).toBe(0); + }); + + test('assigns A to a single problem', () => { + expect(assignProblemLetters(['1100'])).toEqual(new Map([['1100', 'A']])); + }); + }); +}); + describe('mapToTask', () => { const baseTask: AOJTaskAPI = { id: 'ITP1_1_A', @@ -117,6 +172,14 @@ describe('mapToTask', () => { expect(result.task_id).toBe(baseTask.id); }); + test('uses the given problemIndex instead of the problem id', () => { + const icpcTask: AOJTaskAPI = { ...baseTask, id: '1677', name: 'Billiards' }; + const result = mapToTask(icpcTask, 'ICPCPrelim2024', 'F'); + + expect(result.problem_index).toBe('F'); + expect(result.task_id).toBe('1677'); + }); + test('uses problem.name as title', () => { const result = mapToTask(baseTask, 'ITP1'); expect(result.title).toBe('Hello, World!'); diff --git a/src/lib/clients/aizu_online_judge/utils.ts b/src/lib/clients/aizu_online_judge/utils.ts index 5406393d9..20277a670 100644 --- a/src/lib/clients/aizu_online_judge/utils.ts +++ b/src/lib/clients/aizu_online_judge/utils.ts @@ -51,19 +51,40 @@ export function mapToContest(contestId: string, title: string): ContestForImport }; } +/** + * Assigns problem letters (A, B, C...) to the problems of one contest. + * + * The AOJ API does not tell a problem's position in its contest, so this follows the basic rule: + * letters in ascending numeric order of problem ids. A problem missing from AOJ's contest listing + * shifts the later letters, which cannot be detected here. + * + * @param problemIds - Numeric AOJ problem ids of one contest. + * @returns A map from problem id to problem letter. + */ +export function assignProblemLetters(problemIds: string[]): Map { + const sortedIds = [...problemIds].sort((first, second) => Number(first) - Number(second)); + + return new Map(sortedIds.map((problemId, i) => [problemId, String.fromCharCode(65 + i)])); +} + /** * Maps the AOJTaskAPI problem object to a task object. * * @param problem - The problem object from AOJTaskAPI. * @param contestId - The ID of the contest. + * @param problemIndex - The problem index. Defaults to the problem id. * @returns An object representing the task. */ -export function mapToTask(problem: AOJTaskAPI, contestId: string) { +export function mapToTask( + problem: AOJTaskAPI, + contestId: string, + problemIndex: string = problem.id, // AtCoder Problems API uses the same substitute for old JOI problems. +) { return { id: problem.id, contest_id: contestId, - problem_index: problem.id, // Using task.id as a substitute since there's no equivalent to problem_index. Similar approach is used in AtCoder Problems API for old JOI problems. - task_id: problem.id, // Same as above + problem_index: problemIndex, + task_id: problem.id, title: problem.name.trim(), }; } diff --git a/src/lib/utils/task.test.ts b/src/lib/utils/task.test.ts new file mode 100644 index 000000000..7515eb068 --- /dev/null +++ b/src/lib/utils/task.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from 'vitest'; + +import { removeTaskIndexFromTitle, buildTitleWithTaskIndex } from '#lib/utils/task.js'; + +describe('removeTaskIndexFromTitle', () => { + describe('successful cases', () => { + test('removes the task index prefix at the start of the title', () => { + expect(removeTaskIndexFromTitle('A. Jiro', 'A')).toBe('Jiro'); + }); + }); + + describe('edge cases', () => { + test('keeps ". " that appears inside the title', () => { + expect(removeTaskIndexFromTitle('Plan B. Escape', 'B')).toBe('Plan B. Escape'); + }); + + test('keeps ". " inside the title when no task index is given', () => { + expect(removeTaskIndexFromTitle('Dr. Podboq or: How We Became Asymmetric')).toBe( + 'Dr. Podboq or: How We Became Asymmetric', + ); + }); + }); +}); + +describe('buildTitleWithTaskIndex', () => { + describe('when the title already starts with the task index (AtCoder)', () => { + test.each([ + { title: 'A. Jiro', taskTableIndex: 'A', expected: 'A. Jiro' }, + { title: 'EX10. 1.10', taskTableIndex: 'EX10', expected: 'EX10. 1.10' }, + { + title: '001. Yokan Party(★4)', + taskTableIndex: '001', + expected: '001. Yokan Party(★4)', + }, + ])('keeps "$expected" without a duplicated prefix', ({ title, taskTableIndex, expected }) => { + expect(buildTitleWithTaskIndex(title, taskTableIndex)).toBe(expected); + }); + }); + + describe('when the title has no task index (AOJ ICPC / JAG Prelim)', () => { + test.each([ + { title: 'Snacks within 300 Yen', taskTableIndex: 'A', expected: 'A. Snacks within 300 Yen' }, + { title: 'Billiards', taskTableIndex: 'F', expected: 'F. Billiards' }, + { title: 'Peculiar Protocol', taskTableIndex: 'L', expected: 'L. Peculiar Protocol' }, + ])('prefixes the task index to build "$expected"', ({ title, taskTableIndex, expected }) => { + expect(buildTitleWithTaskIndex(title, taskTableIndex)).toBe(expected); + }); + + test('keeps ". " that appears inside the title', () => { + expect(buildTitleWithTaskIndex('Plan B. Escape', 'B')).toBe('B. Plan B. Escape'); + }); + }); +}); diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index f5ac762f0..298714a79 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -121,9 +121,16 @@ export const getTaskTableHeaderName = (contestType: ContestType, taskResult: Tas // 理由: 問題を解くときに、プレフィックスからの先入観を受けないようにするため // その他: プレフィックスは、同じテーブルの出典に記載する export const removeTaskIndexFromTitle = (title: string, taskTableIndex: string = '') => { - return title.replace(`${taskTableIndex}. `, ''); + const prefix = `${taskTableIndex}. `; + + // Only a leading prefix: letter indices (e.g. "B") can also occur inside the title body. + return title.startsWith(prefix) ? title.slice(prefix.length) : title; }; +// Strip first so titles that already carry the index (AtCoder) are not prefixed twice. +export const buildTitleWithTaskIndex = (title: string, taskTableIndex: string): string => + `${taskTableIndex}. ${removeTaskIndexFromTitle(title, taskTableIndex)}`; + export const taskGradeOrderInfinity = 9999; // order: 1 (first) - 17 (last)、9999: Infinity