From c93e203bba05d085293184660fcf3cfc2a0d626f Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Tue, 6 Oct 2026 12:59:17 +0000 Subject: [PATCH 1/5] docs: add plan for adding Game 24 contest table Co-Authored-By: Claude Opus 5.5 --- .../add-game-24-contest-table/plan.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md diff --git a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md new file mode 100644 index 000000000..9199c5f55 --- /dev/null +++ b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md @@ -0,0 +1,127 @@ +# 組合せゲーム 24 題(game-24)のインポートと xDPC グループへの追加 + +## 概要 + +[組合せゲーム 24 題](https://atcoder.jp/contests/game-24) を、新しい ContestType `GAME_24` としてインポートできるようにし、問題一覧の xDPC グループに表示する。 + +- contest_id は `game-24`、問題は A〜X の 24 問。 +- task_id は `game_a`〜`game_x` で、contest_id の `24` を含まない(`fps-24` の `fps_24_a` とは形式が異なる)。 +- `GAME_24` は enum・priority ともに `FPS_24` の直後に置く。 +- テーブルは xDPC グループの「FPS 24 題」の後に追加し、グループ名を「xDPC・x 24 題」に改名する。 +- 本番 DB には、main への反映後に管理画面 `/tasks` からインポートする。マイグレーションは CI の本番ジョブ(`.github/workflows/ci.yml`)がデプロイ前に `prisma migrate deploy` で適用する。インポート時に `classifyContest('game-24')` が `GAME_24` を返すことが前提になる。 + +参考: `.agents/skills/add-contest-table-provider/`、FPS 24 追加時の #2796(インポート)と #2797(テーブル)、ラベルを種別ごとに分割した #4021。 + +## 決定事項 + +| 項目 | 値 | +| ------------------------------ | ---------------------------------------------------------------------------- | +| 実装パターン | Pattern 2(単一 contest_id) | +| enum コメント | `// 24 Problems on Combinatorial Games` | +| コンテスト名ラベル・表タイトル | `組合せゲーム 24 題` | +| `abbreviationName` | `game-24` | +| グループ名・`buttonLabel` | `xDPC・x 24 題` | +| `ariaLabel` | `xDPC and x 24 contests` | +| プリセットキー | `dps` のまま(画面に表示されないため) | +| 表示設定 | FPS 24 と同じ | +| seed | `prisma/tasks.ts` に 24 行を追加し、grade は付けない(問題名はユーザー提供) | +| PR | 1 PR・3 コミット(plan / Layer 1–3 と seed / Layer 4–5 と E2E) | +| レビュー | DB スキーマとマイグレーションを含むため、Codex でクロスレビューする | + +## 設計判断 + +- **分類:** contest_id の完全一致で判定できるので、`CONTEST_TYPES_BY_ID` に `['game-24', ContestType.GAME_24]` を追加する。`CLASSIFICATION_RULES` には手を入れない。 +- **priority:** `GAME_24` を 20 にし、それ以降を 1 つずつずらす(`ATCODER_MAIN_OFFICIAL_ONSITE` 21、`OTHERS` 22、AOJ 系 23〜27)。JSDoc の範囲表記も `0 = Highest, 27 = Lowest`、Special contests `18-22`、External platforms `23-27` に直す。`src/test/lib/utils/task.test.ts` の差分期待値は NDPC・PAST・JOI など 19 以前の種別だけを使っているので、変更は要らない見込み(Phase 1 で実行して確認する)。 +- **task_id の形式:** AtCoder の問題 URL は `getTaskUrl` が contestId と taskId を連結して作るだけで(`src/lib/utils/task.ts:23`)、task_id を分解して contest_id を求める処理は AOJ クライアント以外にない。そのため `game_a` 形式でも動作に影響しない。テストのフィクスチャには実際の `game_a` を使い、この非対称を明示する。 +- **Provider:** EDPC / TDPC / NDPC / FPS24 と同じく、クラスを複製して `game24_provider.ts` を新しく作る。 +- **enum の物理順序:** PostgreSQL の `ALTER TYPE ... ADD VALUE` は末尾に追加するので、DB 上の enum の並びは schema と一致しない。ただし過去の追加分も同じ状態で、表示順は `contestTypePriorities` で管理しているため問題ない。 + +### 提案シグネチャ + +```typescript +// src/features/tasks/utils/contest-table/game24_provider.ts +export class Game24Provider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean; // contest_id === 'game-24' + getMetadata(): ContestTableMetaData; // { title: '組合せゲーム 24 題', abbreviationName: 'game-24' } + getDisplayConfig(): ContestTableDisplayConfig; // FPS24Provider と同値 + getContestRoundLabel(_contestId: string): string; // '' +} +``` + +```typescript +// contest_table_provider_groups.ts(変更後) +dps: () => + new ContestTableProviderGroup(`xDPC・x 24 題`, { + buttonLabel: 'xDPC・x 24 題', + ariaLabel: 'xDPC and x 24 contests', + }).addProviders( + new EDPCProvider(ContestType.EDPC), + new TDPCProvider(ContestType.TDPC), + new NDPCProvider(ContestType.NDPC), + new FPS24Provider(ContestType.FPS_24), + new Game24Provider(ContestType.GAME_24), + ), +``` + +## 見送り案 + +- **`x24_providers.ts` に統合する:** `dp_providers.ts` と同じ構成になるが、`fps24_provider.ts` のリネームで差分が膨らむ。必要になった時点で行えばよい。 +- **単一 contest_id 用の共通基底クラスを抽出する:** DP 系 3 クラスにも波及し、本タスクの範囲を超える。 +- **2 PR に分割する:** FPS 24 のときと同じ分け方だが、変更規模が小さく、1 PR 内でコミットを分ければレビューに支障はない。 +- **プリセットキー `dps` を改名する:** 画面に表示されず、activeContestType の保存値が変わると既存ユーザーの選択状態が失われるおそれがある。 +- **ボタンに `aria-pressed` を付ける:** E2E で選択状態を検証しやすくなるが、UI の変更になるため別タスクにする。 +- **seed に grade を付ける:** fps-24 と同様に PENDING とし、本番では投票または管理画面で設定する。 + +## フェーズ + +### Phase 0: seed データの準備(データのみ) + +- 問題名の一覧をユーザーから受け取る(取得元の AtCoder ページと problems.json は、エージェント環境から取得できなかった)。 +- `prisma/tasks.ts` の `fps_24_x` ブロックの直前に、fps-24 と同じく `game_x` → `game_a` の降順で 24 行を追加する(`id: 'game_a'`、`contest_id: 'game-24'`、`problem_index: 'A'`、`name`、`title: 'A. '`)。 +- 確認: `grep -c "contest_id: 'game-24'" prisma/tasks.ts` が 24 になること。 + +### Phase 1: ContestType の追加(Layer 1–3、コミット 2) + +レイヤー: DB schema / Domain types / Utilities(`src/lib/contests/`)。 + +1. `prisma/schema.prisma` の `FPS_24` の直後に `GAME_24 // 24 Problems on Combinatorial Games` を追加する。続けて `pnpm exec prisma migrate dev --name add_game_24_to_contest_type` と `pnpm exec prisma generate` を実行する。 +2. `src/lib/contests/types/contest.ts` の `FPS_24` の直後に `GAME_24` を追加する。 +3. テストを先に書く(RED)。 + - `src/lib/contests/fixtures/contest_type.ts` に `game24`(`createTestCaseForContestType('Game 24')`、`'game-24'` → `ContestType.GAME_24`)を追加する。 + - `src/lib/contests/fixtures/contest_name_labels.ts` に `game24`(`'game-24'` → `'組合せゲーム 24 題'`)を追加する。 + - `classification.test.ts` に `describe('when contest_id is game-24')` を追加する。 + - `labels/index.test.ts` に `describe('when contest_id is game-24')` を追加する。 + - `priority.test.ts` に `test('ranks GAME_24 directly below FPS_24')` を追加する。既存の `assigns a priority to every contest type` も、enum の追加によって RED になる。 +4. 実装する(GREEN)。`CONTEST_TYPES_BY_ID`、`contestTypePriorities` と JSDoc、`LABEL_GENERATORS` に `[ContestType.GAME_24, () => '組合せゲーム 24 題']` を追加する。 +5. `pnpm test:unit src/lib/contests/ src/test/lib/utils/task.test.ts` を実行する。 + +### Phase 2: Provider とグループ登録(Layer 4–5、コミット 3) + +レイヤー: Utilities(`src/features/tasks/utils/contest-table/`)と Test data。 + +1. テストを先に書く(RED)。 + - `src/features/tasks/fixtures/contest-table/contest_table_provider.ts` に `taskResultsForGame24Provider` を追加する(`game_a`、`game_b`、`game_m`、`game_x` の 4 問。ステータスは FPS 24 と同じ組み合わせにする)。 + - `game24_provider.test.ts` を `fps24_provider.test.ts` に倣って作る。テスト名は `expects to get correct metadata`、`expects to get correct display configuration`、`expects to format contest round label correctly`、`expects to filter tasks to include only game-24 contest`、`expects to generate correct table structure` など。フィルタのテストでは `fps-24` を混ぜ、除外されることを確認する。 + - `contest_table_provider_groups.test.ts` のテスト名を `dps registers EDPC, TDPC, NDPC, FPS 24, and Game 24 providers` に変え、グループ名・`buttonLabel`・`ariaLabel`・`getSize() === 5`・`getProvider(ContestType.GAME_24)` を検証する。 +2. 実装する(GREEN)。`game24_provider.ts` を作成し、`contest_table_provider_groups.ts` で import と `addProviders` の末尾への追加、JSDoc(`DP group (EDPC, TDPC, NDPC, FPS 24, and Game 24)`)の更新を行う。 +3. `pnpm test:unit src/features/tasks/utils/contest-table/` を実行する。 + +### Phase 3: E2E とドキュメント(コミット 3 に含める) + +1. `e2e/problems_contest_table.spec.ts` を新しく作る。未ログインで `/problems` を開き、`getByRole('button', { name: 'xDPC and x 24 contests' })` をクリックして、見出しが EDPC → TDPC → NDPC → `FPS 24 題` → `組合せゲーム 24 題` の順に並ぶことを確認する。テスト名は `shows Game 24 table right after FPS 24 in the xDPC group`。見出しは tasks の有無に関係なく描画されるので、seed には依存しない。 +2. `docs/guides/how-to-add-contest-table-provider.md` の単一ソース型の表に `| GAME_24 | 'game-24' | 24問 | A~X |` を追加する。task_id が `game_a` 形式であることも脚注で補足する。 +3. `pnpm test:e2e e2e/problems_contest_table.spec.ts` を実行する。 + +### Phase 4: 検証とレビュー + +1. `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`git diff --check` を実行する。 +2. 残存参照を確認する: `rg -n "xDPC・FPS 24|xDPC and FPS 24" -g '*.ts' -g '*.svelte' -g '*.md'` が `docs/dev-notes/` 以外で 0 件になること。 +3. Codex でクロスレビューする。指摘の修正は、ユーザーが選択した後に行う。 + +### リリース後の作業(人手) + +マイグレーションは、main への push 時に CI の本番ジョブが自動で適用するので、手動の操作は要らない。 + +1. CI の本番ジョブが成功したことを確認する。 +2. 管理画面 `/tasks` で AtCoder ソースから取得し、`game-24` の 24 問をインポートする(AtCoder Problems に反映されていなければ、反映を待つ)。 +3. `/problems` の「xDPC・x 24 題」で、テーブルが表示されることを確認する。 From 545ad86652c97acd2e3e4a5bcdc8e21f60197460 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Tue, 6 Oct 2026 13:00:48 +0000 Subject: [PATCH 2/5] feat(contests): add GAME_24 contest type for game-24 Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 2 + prisma/schema.prisma | 1 + prisma/tasks.ts | 168 ++++++++++++++++++ .../contests/fixtures/contest_name_labels.ts | 7 + src/lib/contests/fixtures/contest_type.ts | 7 + src/lib/contests/types/contest.ts | 1 + src/lib/contests/utils/classification.test.ts | 8 + src/lib/contests/utils/classification.ts | 1 + src/lib/contests/utils/labels/index.test.ts | 8 + src/lib/contests/utils/labels/index.ts | 1 + src/lib/contests/utils/priority.test.ts | 4 + src/lib/contests/utils/priority.ts | 21 +-- 12 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql diff --git a/prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql b/prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql new file mode 100644 index 000000000..42fbe31bd --- /dev/null +++ b/prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ContestType" ADD VALUE 'GAME_24'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1dde3079e..61114f9d1 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -314,6 +314,7 @@ enum ContestType { AWC // AtCoder Weekday Contest UNIVERSITY // University Programming Contests in AtCoder (e.g., UTPC) FPS_24 // 24 Problems on Formal Power Series + GAME_24 // 24 Problems on Combinatorial Games ATCODER_MAIN_OFFICIAL_ONSITE // AtCoder World Tour Finals (official onsite finals) OTHERS // AtCoder (その他) AOJ_COURSES // AIZU ONLINE JUDGE Courses diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 1828cc8c0..657357df0 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -8809,6 +8809,174 @@ export const tasks = [ name: 'Bacteria Growth Experiment', title: 'A. Bacteria Growth Experiment', }, + { + id: 'game_x', + contest_id: 'game-24', + problem_index: 'X', + name: 'XOR Bag', + title: 'X. XOR Bag', + }, + { + id: 'game_w', + contest_id: 'game-24', + problem_index: 'W', + name: 'Parity Game', + title: 'W. Parity Game', + }, + { + id: 'game_v', + contest_id: 'game-24', + problem_index: 'V', + name: 'Delete Game', + title: 'V. Delete Game', + }, + { + id: 'game_u', + contest_id: 'game-24', + problem_index: 'U', + name: 'Rectangle Flip Game', + title: 'U. Rectangle Flip Game', + }, + { + id: 'game_t', + contest_id: 'game-24', + problem_index: 'T', + name: 'Consecutive Move Game', + title: 'T. Consecutive Move Game', + }, + { + id: 'game_s', + contest_id: 'game-24', + problem_index: 'S', + name: 'Odd and Even', + title: 'S. Odd and Even', + }, + { + id: 'game_r', + contest_id: 'game-24', + problem_index: 'R', + name: 'Delete and Divide', + title: 'R. Delete and Divide', + }, + { + id: 'game_q', + contest_id: 'game-24', + problem_index: 'Q', + name: 'Moving Pieces on Graph', + title: 'Q. Moving Pieces on Graph', + }, + { + id: 'game_p', + contest_id: 'game-24', + problem_index: 'P', + name: 'Game on Tree', + title: 'P. Game on Tree', + }, + { + id: 'game_o', + contest_id: 'game-24', + problem_index: 'O', + name: 'Moving Pieces on Grid', + title: 'O. Moving Pieces on Grid', + }, + { + id: 'game_n', + contest_id: 'game-24', + problem_index: 'N', + name: 'One and All', + title: 'N. One and All', + }, + { + id: 'game_m', + contest_id: 'game-24', + problem_index: 'M', + name: 'LRUD Game', + title: 'M. LRUD Game', + }, + { + id: 'game_l', + contest_id: 'game-24', + problem_index: 'L', + name: 'Unfair Nim', + title: 'L. Unfair Nim', + }, + { + id: 'game_k', + contest_id: 'game-24', + problem_index: 'K', + name: 'Domino in Tetromino 2', + title: 'K. Domino in Tetromino 2', + }, + { + id: 'game_j', + contest_id: 'game-24', + problem_index: 'J', + name: 'Restricted Nim 2', + title: 'J. Restricted Nim 2', + }, + { + id: 'game_i', + contest_id: 'game-24', + problem_index: 'I', + name: 'Necklace 2', + title: 'I. Necklace 2', + }, + { + id: 'game_h', + contest_id: 'game-24', + problem_index: 'H', + name: 'Lexicographic Order Game', + title: 'H. Lexicographic Order Game', + }, + { + id: 'game_g', + contest_id: 'game-24', + problem_index: 'G', + name: 'Round Nim', + title: 'G. Round Nim', + }, + { + id: 'game_f', + contest_id: 'game-24', + problem_index: 'F', + name: 'Necklace 1', + title: 'F. Necklace 1', + }, + { + id: 'game_e', + contest_id: 'game-24', + problem_index: 'E', + name: 'Restricted Nim 1', + title: 'E. Restricted Nim 1', + }, + { + id: 'game_d', + contest_id: 'game-24', + problem_index: 'D', + name: 'Parallel Nim', + title: 'D. Parallel Nim', + }, + { + id: 'game_c', + contest_id: 'game-24', + problem_index: 'C', + name: 'Domino in Tetromino 1', + title: 'C. Domino in Tetromino 1', + }, + { + id: 'game_b', + contest_id: 'game-24', + problem_index: 'B', + name: 'Chocolate', + title: 'B. Chocolate', + }, + { + id: 'game_a', + contest_id: 'game-24', + problem_index: 'A', + name: 'Nim', + title: 'A. Nim', + }, { id: 'fps_24_x', contest_id: 'fps-24', diff --git a/src/lib/contests/fixtures/contest_name_labels.ts b/src/lib/contests/fixtures/contest_name_labels.ts index 6ff6f13c2..5b32407d7 100644 --- a/src/lib/contests/fixtures/contest_name_labels.ts +++ b/src/lib/contests/fixtures/contest_name_labels.ts @@ -139,6 +139,13 @@ export const fps24 = [ }), ]; +export const game24 = [ + createTestCaseForContestNameLabel('Game 24')({ + contestId: 'game-24', + expected: '組合せゲーム 24 題', + }), +]; + export const aojCourses = [ createTestCaseForContestNameLabel('AOJ, ITP1')({ contestId: 'ITP1', diff --git a/src/lib/contests/fixtures/contest_type.ts b/src/lib/contests/fixtures/contest_type.ts index de2be1afb..32663ea60 100644 --- a/src/lib/contests/fixtures/contest_type.ts +++ b/src/lib/contests/fixtures/contest_type.ts @@ -170,6 +170,13 @@ export const fps24 = [ }), ]; +export const game24 = [ + createTestCaseForContestType('Game 24')({ + contestId: 'game-24', + expected: ContestType.GAME_24, + }), +]; + const arcContestIds = [ 'arc001', 'arc002', diff --git a/src/lib/contests/types/contest.ts b/src/lib/contests/types/contest.ts index 7f59933f1..98200fff5 100644 --- a/src/lib/contests/types/contest.ts +++ b/src/lib/contests/types/contest.ts @@ -48,6 +48,7 @@ export const ContestType: { [key in ContestTypeOrigin]: key } = { AWC: 'AWC', // AtCoder Weekday Contest UNIVERSITY: 'UNIVERSITY', // University Programming Contests in AtCoder (e.g., UTPC) FPS_24: 'FPS_24', // 24 Problems on Formal Power Series + GAME_24: 'GAME_24', // 24 Problems on Combinatorial Games ATCODER_MAIN_OFFICIAL_ONSITE: 'ATCODER_MAIN_OFFICIAL_ONSITE', // AtCoder World Tour Finals (official onsite finals) OTHERS: 'OTHERS', // AtCoder (その他) AOJ_COURSES: 'AOJ_COURSES', // AIZU ONLINE JUDGE Courses diff --git a/src/lib/contests/utils/classification.test.ts b/src/lib/contests/utils/classification.test.ts index a5ab90258..2dd2e0d4e 100644 --- a/src/lib/contests/utils/classification.test.ts +++ b/src/lib/contests/utils/classification.test.ts @@ -112,6 +112,14 @@ describe('classify contest', () => { }); }); + describe('when contest_id is game-24', () => { + TestCasesForContestType.game24.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + describe('when contest_id contains arc', () => { TestCasesForContestType.arc.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { diff --git a/src/lib/contests/utils/classification.ts b/src/lib/contests/utils/classification.ts index 13d293864..e4eb58ab0 100644 --- a/src/lib/contests/utils/classification.ts +++ b/src/lib/contests/utils/classification.ts @@ -31,6 +31,7 @@ const CONTEST_TYPES_BY_ID: ReadonlyMap = new Map([ ['tessoku-book', ContestType.TESSOKU_BOOK], ['math-and-algorithm', ContestType.MATH_AND_ALGORITHM], ['fps-24', ContestType.FPS_24], + ['game-24', ContestType.GAME_24], ]); type ClassificationRule = { diff --git a/src/lib/contests/utils/labels/index.test.ts b/src/lib/contests/utils/labels/index.test.ts index 7c45fc57e..c6fd1856e 100644 --- a/src/lib/contests/utils/labels/index.test.ts +++ b/src/lib/contests/utils/labels/index.test.ts @@ -98,6 +98,14 @@ describe('get contest name label', () => { }); }); + describe('when contest_id is game-24', () => { + TestCasesForContestNameLabel.game24.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + describe('when contest_id contains awc', () => { TestCasesForContestNameLabel.awc.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { diff --git a/src/lib/contests/utils/labels/index.ts b/src/lib/contests/utils/labels/index.ts index 5d1bc2fdd..4f04ccd94 100644 --- a/src/lib/contests/utils/labels/index.ts +++ b/src/lib/contests/utils/labels/index.ts @@ -34,6 +34,7 @@ export const LABEL_GENERATORS: ReadonlyMap = new Ma [ContestType.TESSOKU_BOOK, () => '競技プログラミングの鉄則'], [ContestType.MATH_AND_ALGORITHM, () => 'アルゴリズムと数学'], [ContestType.FPS_24, () => 'FPS 24 題'], + [ContestType.GAME_24, () => '組合せゲーム 24 題'], [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, (id) => getWorldTourFinalsLabel(id)], [ContestType.UNIVERSITY, (id) => getAtCoderUniversityContestLabel(id)], [ContestType.OTHERS, generateOthersLabel], diff --git a/src/lib/contests/utils/priority.test.ts b/src/lib/contests/utils/priority.test.ts index e59927e38..8782099f1 100644 --- a/src/lib/contests/utils/priority.test.ts +++ b/src/lib/contests/utils/priority.test.ts @@ -124,5 +124,9 @@ describe('contest type priorities', () => { expect(priorityOf(ContestType.ARC_LIKE)).toBeGreaterThan(priorityOf(ContestType.ARC)); expect(priorityOf(ContestType.AGC_LIKE)).toBeGreaterThan(priorityOf(ContestType.AGC)); }); + + test('ranks GAME_24 directly below FPS_24', () => { + expect(priorityOf(ContestType.GAME_24)).toBe(priorityOf(ContestType.FPS_24) + 1); + }); }); }); diff --git a/src/lib/contests/utils/priority.ts b/src/lib/contests/utils/priority.ts index 9b96e4feb..97ce14819 100644 --- a/src/lib/contests/utils/priority.ts +++ b/src/lib/contests/utils/priority.ts @@ -2,13 +2,13 @@ import { ContestType } from '$lib/contests/types/contest'; import { classifyContest } from './classification'; /** - * Contest type priorities (0 = Highest, 26 = Lowest) + * Contest type priorities (0 = Highest, 27 = Lowest) * * Priority assignment rationale: * - Educational contests (0-11, 17): ABS, ABC, APG4B and AWC etc. * - Contests for genius (12-16): ARC, AGC, and their variants - * - Special contests (18-21): UNIVERSITY, FPS_24, ATCODER_MAIN_OFFICIAL_ONSITE, OTHERS - * - External platforms (22-26): AOJ_COURSES, AOJ_PCK, AOJ_ICPC, AOJ_JAG, AOJ_UNIVERSITY + * - Special contests (18-22): UNIVERSITY, FPS_24, GAME_24, ATCODER_MAIN_OFFICIAL_ONSITE, OTHERS + * - External platforms (23-27): AOJ_COURSES, AOJ_PCK, AOJ_ICPC, AOJ_JAG, AOJ_UNIVERSITY * * @remarks * HACK: The priorities for ARC, AGC, UNIVERSITY, AOJ_COURSES, and AOJ_PCK are temporary @@ -38,13 +38,14 @@ export const contestTypePriorities: Map = new Map([ [ContestType.AWC, 17], [ContestType.UNIVERSITY, 18], [ContestType.FPS_24, 19], - [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, 20], - [ContestType.OTHERS, 21], // AtCoder (その他) - [ContestType.AOJ_COURSES, 22], - [ContestType.AOJ_PCK, 23], - [ContestType.AOJ_ICPC, 24], - [ContestType.AOJ_JAG, 25], - [ContestType.AOJ_UNIVERSITY, 26], + [ContestType.GAME_24, 20], + [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, 21], + [ContestType.OTHERS, 22], // AtCoder (その他) + [ContestType.AOJ_COURSES, 23], + [ContestType.AOJ_PCK, 24], + [ContestType.AOJ_ICPC, 25], + [ContestType.AOJ_JAG, 26], + [ContestType.AOJ_UNIVERSITY, 27], ]); /** Priority given to a contest_id that no classification rule matches, so it sorts last. */ From e1d56c94219b3c4405c200d843f91eedb255890c Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Tue, 6 Oct 2026 13:03:28 +0000 Subject: [PATCH 3/5] feat(table): add Game 24 to xDPC contest table group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the group label to xDPC・x 24 題 and place Game 24 after FPS 24. Co-Authored-By: Claude Opus 5.5 --- .../add-game-24-contest-table/plan.md | 2 +- .../how-to-add-contest-table-provider.md | 3 + e2e/problems_contest_table.spec.ts | 21 ++++ .../contest_table_provider_groups.test.ts | 11 +- .../contest_table_provider_groups.ts | 10 +- .../contest-table/game24_provider.test.ts | 109 ++++++++++++++++++ .../utils/contest-table/game24_provider.ts | 42 +++++++ 7 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 e2e/problems_contest_table.spec.ts create mode 100644 src/features/tasks/utils/contest-table/game24_provider.test.ts create mode 100644 src/features/tasks/utils/contest-table/game24_provider.ts diff --git a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md index 9199c5f55..d5969c8ef 100644 --- a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md +++ b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md @@ -100,7 +100,7 @@ dps: () => レイヤー: Utilities(`src/features/tasks/utils/contest-table/`)と Test data。 1. テストを先に書く(RED)。 - - `src/features/tasks/fixtures/contest-table/contest_table_provider.ts` に `taskResultsForGame24Provider` を追加する(`game_a`、`game_b`、`game_m`、`game_x` の 4 問。ステータスは FPS 24 と同じ組み合わせにする)。 + - (実装時に見送り)`taskResultsForGame24Provider` フィクスチャは追加しない。既存の `taskResultsForFPS24Provider` はどのテストからも参照されておらず、Provider テストはデータをテスト内に直接書いているため(YAGNI)。 - `game24_provider.test.ts` を `fps24_provider.test.ts` に倣って作る。テスト名は `expects to get correct metadata`、`expects to get correct display configuration`、`expects to format contest round label correctly`、`expects to filter tasks to include only game-24 contest`、`expects to generate correct table structure` など。フィルタのテストでは `fps-24` を混ぜ、除外されることを確認する。 - `contest_table_provider_groups.test.ts` のテスト名を `dps registers EDPC, TDPC, NDPC, FPS 24, and Game 24 providers` に変え、グループ名・`buttonLabel`・`ariaLabel`・`getSize() === 5`・`getProvider(ContestType.GAME_24)` を検証する。 2. 実装する(GREEN)。`game24_provider.ts` を作成し、`contest_table_provider_groups.ts` で import と `addProviders` の末尾への追加、JSDoc(`DP group (EDPC, TDPC, NDPC, FPS 24, and Game 24)`)の更新を行う。 diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 5a72e7463..f1b028c1c 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -115,12 +115,15 @@ AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回 | TDPC | `'tdpc'` | 26問 | A~Z | | NDPC | `'ndpc'` | 20問 | A~T | | FPS_24 | `'fps-24'` | 24問 | A~X | +| GAME_24‡ | `'game-24'` | 24問 | A~X | | ACL_PRACTICE | `'practice2'` | 12問 | A~L | | ACL_BEGINNER / ACL_CONTEST1 | `'abl'`/`'acl1'` | 6問 | A~F | | AWC0100 / AWC0150† | `'awc0100'`/`'awc0150'` | 15問 | A~O | †注: ContestType.AWC を再利用し、section(`'0100'`, `'0150'`, `'0151Onwards'`)で provider key を一意化。ACL 系は `Acl` グループ、DP 系は `dps` グループで統一管理。 +‡注: task_id は `game_a`〜`game_x` で、contest_id の数字を含まない(`fps-24` は `fps_24_a`)。 + ### コンストラクタパラメータ型 / regex フィルタ型 | コンテスト | フィルタ方式 | 備考 | diff --git a/e2e/problems_contest_table.spec.ts b/e2e/problems_contest_table.spec.ts new file mode 100644 index 000000000..669eb54f4 --- /dev/null +++ b/e2e/problems_contest_table.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +const XDPC_GROUP_ARIA_LABEL = 'xDPC and x 24 contests'; +const XDPC_GROUP_TABLE_TITLES = [ + 'Educational DP Contest / DP まとめコンテスト', + 'Typical DP Contest', + 'Next DP Contest', + 'FPS 24 題', + '組合せゲーム 24 題', +]; + +test.describe('xDPC group in contest table', () => { + test('shows Game 24 table right after FPS 24 in the xDPC group', async ({ page }) => { + await page.goto('/problems'); + + await page.getByRole('button', { name: XDPC_GROUP_ARIA_LABEL }).click(); + + // Headings render even when the seed has no tasks for a provider, so this stays seed-independent. + await expect(page.getByRole('heading', { level: 2 })).toHaveText(XDPC_GROUP_TABLE_TITLES); + }); +}); diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts index f879b1efd..0c6d0c6f1 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts @@ -168,21 +168,22 @@ describe('prepareContestProviderPresets', () => { } }); - test('dps registers EDPC, TDPC, NDPC, and FPS 24 providers', () => { + test('dps registers EDPC, TDPC, NDPC, FPS 24, and Game 24 providers', () => { const group = presets.dps(); - expect(group.getGroupName()).toBe('xDPC・FPS 24'); + expect(group.getGroupName()).toBe('xDPC・x 24 題'); expect(group.getMetadata()).toEqual({ - buttonLabel: 'xDPC・FPS 24', - ariaLabel: 'xDPC and FPS 24 contests', + buttonLabel: 'xDPC・x 24 題', + ariaLabel: 'xDPC and x 24 contests', }); - expect(group.getSize()).toBe(4); + expect(group.getSize()).toBe(5); const contestTypes = [ ContestType.EDPC, ContestType.TDPC, ContestType.NDPC, ContestType.FPS_24, + ContestType.GAME_24, ]; for (const contestType of contestTypes) { diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts index 98e7da299..f1733a393 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts @@ -25,6 +25,7 @@ import { import { MathAndAlgorithmProvider } from './math_and_algorithm_provider'; import { EDPCProvider, TDPCProvider, NDPCProvider } from './dp_providers'; import { FPS24Provider } from './fps24_provider'; +import { Game24Provider } from './game24_provider'; import { ACLPracticeProvider, ACLBeginnerProvider, ACLProvider } from './acl_providers'; import { JOIFirstQualRoundProvider, @@ -239,17 +240,18 @@ export const prepareContestProviderPresets = () => { }).addProvider(new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM)), /** - * DP group (EDPC, TDPC, NDPC, and FPS 24) + * DP group (EDPC, TDPC, NDPC, FPS 24, and Game 24) */ dps: () => - new ContestTableProviderGroup(`xDPC・FPS 24`, { - buttonLabel: 'xDPC・FPS 24', - ariaLabel: 'xDPC and FPS 24 contests', + new ContestTableProviderGroup(`xDPC・x 24 題`, { + buttonLabel: 'xDPC・x 24 題', + ariaLabel: 'xDPC and x 24 contests', }).addProviders( new EDPCProvider(ContestType.EDPC), new TDPCProvider(ContestType.TDPC), new NDPCProvider(ContestType.NDPC), new FPS24Provider(ContestType.FPS_24), + new Game24Provider(ContestType.GAME_24), ), /** diff --git a/src/features/tasks/utils/contest-table/game24_provider.test.ts b/src/features/tasks/utils/contest-table/game24_provider.test.ts new file mode 100644 index 000000000..d45805754 --- /dev/null +++ b/src/features/tasks/utils/contest-table/game24_provider.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect } from 'vitest'; + +import { ContestType } from '$lib/contests/types/contest'; +import type { TaskResults } from '$lib/types/task'; + +import { Game24Provider } from './game24_provider'; + +// Task IDs omit the contest number (game_a, not game_24_a), unlike fps-24. +describe('Game24Provider', () => { + test('expects to get correct metadata', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('組合せゲーム 24 題'); + expect(metadata.abbreviationName).toBe('game-24'); + }); + + test('expects to get correct display configuration', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(false); + expect(displayConfig.isShownRoundLabel).toBe(false); + expect(displayConfig.roundLabelWidth).toBe(''); + expect(displayConfig.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + ); + expect(displayConfig.isShownTaskIndex).toBe(true); + }); + + test('expects to format contest round label correctly', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const label = provider.getContestRoundLabel(''); + + expect(label).toBe(''); + }); + + test('expects to filter tasks to include only game-24 contest', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const mixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_b', task_table_index: 'B' }, + ]; + const filtered = provider.filter(mixedTasks as TaskResults); + + expect(filtered?.map((task) => task.task_id)).toEqual(['game_a', 'game_b']); + }); + + test('expects to generate correct table structure', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const tasks = [ + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_b', task_table_index: 'B' }, + { contest_id: 'game-24', task_id: 'game_x', task_table_index: 'X' }, + ]; + const table = provider.generateTable(tasks as TaskResults); + + expect(table).toHaveProperty('game-24'); + expect(table['game-24']).toHaveProperty('A'); + expect(table['game-24']).toHaveProperty('B'); + expect(table['game-24']).toHaveProperty('X'); + expect(table['game-24']['A']).toEqual(expect.objectContaining({ task_id: 'game_a' })); + }); + + test('expects to get contest round IDs correctly', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const tasks = [ + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_x', task_table_index: 'X' }, + ]; + const roundIds = provider.getContestRoundIds(tasks as TaskResults); + + expect(roundIds).toEqual(['game-24']); + }); + + test('expects to get header IDs for tasks correctly in ascending order', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const tasks = [ + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_x', task_table_index: 'X' }, + { contest_id: 'game-24', task_id: 'game_m', task_table_index: 'M' }, + { contest_id: 'game-24', task_id: 'game_b', task_table_index: 'B' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['A', 'B', 'M', 'X']); + }); + + test('expects to handle empty task results', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); + + test('expects to handle task results with different contest types', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const mockMixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'dp', task_id: 'dp_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + ]; + const filtered = provider.filter(mockMixedTasks as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); +}); diff --git a/src/features/tasks/utils/contest-table/game24_provider.ts b/src/features/tasks/utils/contest-table/game24_provider.ts new file mode 100644 index 000000000..8d8060650 --- /dev/null +++ b/src/features/tasks/utils/contest-table/game24_provider.ts @@ -0,0 +1,42 @@ +import { + type ContestTableMetaData, + type ContestTableDisplayConfig, +} from '$features/tasks/types/contest-table/contest_table_provider'; +import type { TaskResult } from '$lib/types/task'; + +import { classifyContest } from '$lib/contests'; + +import { ContestTableProviderBase } from './contest_table_provider_base'; + +export class Game24Provider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return taskResult.contest_id === 'game-24'; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: '組合せゲーム 24 題', + abbreviationName: 'game-24', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', // No specific width for task index in Game 24 + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return ''; + } +} From 7fb10b8ca1d8837994dbfaaa34a8a7549a0d2237 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Tue, 6 Oct 2026 13:13:35 +0000 Subject: [PATCH 4/5] docs: condense Game 24 plan and record lessons in agent rules Co-Authored-By: Claude Opus 5.5 --- .../add-contest-table-provider/SKILL.md | 2 +- .../instructions.md | 5 +- .../add-game-24-contest-table/plan.md | 142 ++++-------------- docs/guides/agent-rules/prisma-db.md | 2 +- docs/guides/agent-rules/svelte-components.md | 5 +- docs/guides/agent-rules/testing.md | 2 +- 6 files changed, 37 insertions(+), 121 deletions(-) diff --git a/.agents/skills/add-contest-table-provider/SKILL.md b/.agents/skills/add-contest-table-provider/SKILL.md index 8fd84db23..286fefe9c 100644 --- a/.agents/skills/add-contest-table-provider/SKILL.md +++ b/.agents/skills/add-contest-table-provider/SKILL.md @@ -8,6 +8,6 @@ Add a new contest table provider for: $ARGUMENTS > When in doubt at any step, use AskUserQuestion before proceeding. -0. **Seed check** — grep `prisma/tasks.ts` for the contest_id(s); report count + task_ids; if absent or incomplete, ask the user to add missing rows (reference: `https://kenkoooo.com/atcoder/resources/problems.json`) +0. **Seed check** — grep `prisma/tasks.ts` for the contest_id(s); report count + task_ids; if absent or incomplete, ask the user to paste the missing rows (source: `https://kenkoooo.com/atcoder/resources/problems.json`). Do not fetch it yourself: atcoder.jp is unreachable, and WebFetch truncates problems.json, so a miss there is not evidence of absence 1. **Gather requirements** — infer the implementation pattern; confirm per [instructions.md §Requirements](instructions.md) 2. **Implement** — follow [instructions.md](instructions.md) for the confirmed pattern across 5 layers (TDD) diff --git a/.agents/skills/add-contest-table-provider/instructions.md b/.agents/skills/add-contest-table-provider/instructions.md index 1bd29cc75..c052159f7 100644 --- a/.agents/skills/add-contest-table-provider/instructions.md +++ b/.agents/skills/add-contest-table-provider/instructions.md @@ -15,6 +15,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - Pattern 2: single fixed contest_id (e.g. NDPC, TDPC, FPS_24) - Pattern 3: multiple contest_ids unified in one table (e.g. ABS, ABC-Like) - Pattern 4: one class instantiated N times via constructor parameter (e.g. ICPC by year) +- task_id format? Do not derive it from contest_id (`game-24` → `game_a`, but `fps-24` → `fps_24_a`) - Nearest neighbor ContestType for insertion order in `contestTypePriorities`? - New group or merge into existing? If new: group name / `buttonLabel` / `ariaLabel`? @@ -32,7 +33,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: > Skip when an existing ContestType already covers this contest family (e.g. `AOJ_JAG` for JAG Prelim). -- [ ] Add to `prisma/schema.prisma` ContestType enum → `pnpm exec prisma generate` +- [ ] Add to `prisma/schema.prisma` ContestType enum → `pnpm exec prisma migrate dev --name add__to_contest_type` (also regenerates the client) - [ ] Add to `ContestType` in `src/lib/contests/types/contest.ts` (same position as schema) - [ ] `pnpm check` — confirm error resolved @@ -51,7 +52,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] `CONTEST_TYPES_BY_ID` or `CLASSIFICATION_RULES` in `src/lib/contests/utils/classification.ts` - [ ] `contestTypePriorities` in `src/lib/contests/utils/priority.ts` - After priority insertion, all later entries shift +1 → **update JSDoc numeric ranges** (4 category names are immutable) - - Fix hardcoded priority-diff expected values in `src/test/lib/utils/task.test.ts` (-1 per shifted entry) + - Fix hardcoded priority-diff expected values in `src/test/lib/utils/task.test.ts` only when they involve a shifted entry (±1 each) - [ ] `LABEL_GENERATORS` in `src/lib/contests/utils/labels/index.ts` (add label generator, create per-type file if complex) - [ ] **GREEN** diff --git a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md index d5969c8ef..0d95d48e4 100644 --- a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md +++ b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md @@ -1,127 +1,43 @@ -# 組合せゲーム 24 題(game-24)のインポートと xDPC グループへの追加 +# 組合せゲーム 24 題(game-24)の追加 ## 概要 -[組合せゲーム 24 題](https://atcoder.jp/contests/game-24) を、新しい ContestType `GAME_24` としてインポートできるようにし、問題一覧の xDPC グループに表示する。 +[組合せゲーム 24 題](https://atcoder.jp/contests/game-24)(contest_id `game-24`、A〜X の 24 問)を ContestType `GAME_24` として追加し、xDPC グループの「FPS 24 題」の後に表示する。あわせて、グループ名を「xDPC・FPS 24」から「xDPC・x 24 題」に改名した。 -- contest_id は `game-24`、問題は A〜X の 24 問。 -- task_id は `game_a`〜`game_x` で、contest_id の `24` を含まない(`fps-24` の `fps_24_a` とは形式が異なる)。 -- `GAME_24` は enum・priority ともに `FPS_24` の直後に置く。 -- テーブルは xDPC グループの「FPS 24 題」の後に追加し、グループ名を「xDPC・x 24 題」に改名する。 -- 本番 DB には、main への反映後に管理画面 `/tasks` からインポートする。マイグレーションは CI の本番ジョブ(`.github/workflows/ci.yml`)がデプロイ前に `prisma migrate deploy` で適用する。インポート時に `classifyContest('game-24')` が `GAME_24` を返すことが前提になる。 +参考: `.agents/skills/add-contest-table-provider/`、FPS 24 追加時の #2796(インポート)と #2797(テーブル)。 -参考: `.agents/skills/add-contest-table-provider/`、FPS 24 追加時の #2796(インポート)と #2797(テーブル)、ラベルを種別ごとに分割した #4021。 +## 前提となる事実 -## 決定事項 - -| 項目 | 値 | -| ------------------------------ | ---------------------------------------------------------------------------- | -| 実装パターン | Pattern 2(単一 contest_id) | -| enum コメント | `// 24 Problems on Combinatorial Games` | -| コンテスト名ラベル・表タイトル | `組合せゲーム 24 題` | -| `abbreviationName` | `game-24` | -| グループ名・`buttonLabel` | `xDPC・x 24 題` | -| `ariaLabel` | `xDPC and x 24 contests` | -| プリセットキー | `dps` のまま(画面に表示されないため) | -| 表示設定 | FPS 24 と同じ | -| seed | `prisma/tasks.ts` に 24 行を追加し、grade は付けない(問題名はユーザー提供) | -| PR | 1 PR・3 コミット(plan / Layer 1–3 と seed / Layer 4–5 と E2E) | -| レビュー | DB スキーマとマイグレーションを含むため、Codex でクロスレビューする | - -## 設計判断 - -- **分類:** contest_id の完全一致で判定できるので、`CONTEST_TYPES_BY_ID` に `['game-24', ContestType.GAME_24]` を追加する。`CLASSIFICATION_RULES` には手を入れない。 -- **priority:** `GAME_24` を 20 にし、それ以降を 1 つずつずらす(`ATCODER_MAIN_OFFICIAL_ONSITE` 21、`OTHERS` 22、AOJ 系 23〜27)。JSDoc の範囲表記も `0 = Highest, 27 = Lowest`、Special contests `18-22`、External platforms `23-27` に直す。`src/test/lib/utils/task.test.ts` の差分期待値は NDPC・PAST・JOI など 19 以前の種別だけを使っているので、変更は要らない見込み(Phase 1 で実行して確認する)。 -- **task_id の形式:** AtCoder の問題 URL は `getTaskUrl` が contestId と taskId を連結して作るだけで(`src/lib/utils/task.ts:23`)、task_id を分解して contest_id を求める処理は AOJ クライアント以外にない。そのため `game_a` 形式でも動作に影響しない。テストのフィクスチャには実際の `game_a` を使い、この非対称を明示する。 -- **Provider:** EDPC / TDPC / NDPC / FPS24 と同じく、クラスを複製して `game24_provider.ts` を新しく作る。 -- **enum の物理順序:** PostgreSQL の `ALTER TYPE ... ADD VALUE` は末尾に追加するので、DB 上の enum の並びは schema と一致しない。ただし過去の追加分も同じ状態で、表示順は `contestTypePriorities` で管理しているため問題ない。 - -### 提案シグネチャ +- **task_id は contest_id の数字を含まない:** `game_a`〜`game_x` であり、`fps-24` の `fps_24_a` とは形式が異なる。AtCoder の問題 URL は `getTaskUrl` が contestId と taskId を連結して作るだけで、AtCoder 側には task_id を分解する処理がないため、この形式でも影響はない。 +- **問題データの入手:** エージェント環境からは atcoder.jp を取得できず(curl は拒否され、WebFetch は失敗)、kenkoooo の problems.json も WebFetch では途中で切れて `game-24` が見つからなかった。seed の 24 行は、ユーザーが貼った一覧から作った。 +- **本番への反映:** マイグレーションは、main への push 時に CI の本番ジョブ(`.github/workflows/ci.yml`)がデプロイ前に `prisma migrate deploy` で適用するので、手動の操作は要らない。問題データは、反映後に管理画面 `/tasks` からインポートする(AtCoder Problems に game-24 が反映されている必要がある)。 -```typescript -// src/features/tasks/utils/contest-table/game24_provider.ts -export class Game24Provider extends ContestTableProviderBase { - protected setFilterCondition(): (taskResult: TaskResult) => boolean; // contest_id === 'game-24' - getMetadata(): ContestTableMetaData; // { title: '組合せゲーム 24 題', abbreviationName: 'game-24' } - getDisplayConfig(): ContestTableDisplayConfig; // FPS24Provider と同値 - getContestRoundLabel(_contestId: string): string; // '' -} -``` +## 決定事項 -```typescript -// contest_table_provider_groups.ts(変更後) -dps: () => - new ContestTableProviderGroup(`xDPC・x 24 題`, { - buttonLabel: 'xDPC・x 24 題', - ariaLabel: 'xDPC and x 24 contests', - }).addProviders( - new EDPCProvider(ContestType.EDPC), - new TDPCProvider(ContestType.TDPC), - new NDPCProvider(ContestType.NDPC), - new FPS24Provider(ContestType.FPS_24), - new Game24Provider(ContestType.GAME_24), - ), -``` +| 項目 | 決定 | 理由・補足 | +| ------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| 位置 | enum・priority ともに `FPS_24` の直後 | ユーザー指定。priority は 20 で、後続の種別は 1 つずつ下がる | +| 表示名 | `組合せゲーム 24 題` | ユーザー指定。「FPS 24 題」に合わせ、数字の前後に半角スペースを入れる | +| グループ名・ボタン | `xDPC・x 24 題` | ユーザー指定。aria-label は `xDPC and x 24 contests` | +| enum コメント | `24 Problems on Combinatorial Games` | `FPS_24` の英語コメントの形式に合わせる | +| プリセットキー | `dps` のまま | localStorage(`contest_table_providers`)に保存されるキーなので、変えると既存ユーザーの選択が失われる | +| Provider | `game24_provider.ts` をクラスごと複製 | EDPC / TDPC / NDPC / FPS24 の既存慣例に合わせる | +| 表示設定 | FPS 24 と同じ | 同じグループ内で見た目を揃える | +| seed の grade | 付けない | fps-24 と同様に PENDING とし、本番では投票か管理画面で設定する | +| フィクスチャ | `taskResultsForGame24Provider` は作らない | FPS 24 用の同等品がどのテストからも参照されていなかった(YAGNI) | +| E2E | ボタンを押した後の h2 見出しの並びを検証する | 見出しは tasks の有無に関係なく描画されるので、seed に依存しない | +| コミット | plan / Layer 1–3 と seed / Layer 4–5 と E2E | 1 PR・3 コミット | ## 見送り案 -- **`x24_providers.ts` に統合する:** `dp_providers.ts` と同じ構成になるが、`fps24_provider.ts` のリネームで差分が膨らむ。必要になった時点で行えばよい。 +- **`x24_providers.ts` に統合する:** `fps24_provider.ts` のリネームで差分が膨らむ。 - **単一 contest_id 用の共通基底クラスを抽出する:** DP 系 3 クラスにも波及し、本タスクの範囲を超える。 -- **2 PR に分割する:** FPS 24 のときと同じ分け方だが、変更規模が小さく、1 PR 内でコミットを分ければレビューに支障はない。 -- **プリセットキー `dps` を改名する:** 画面に表示されず、activeContestType の保存値が変わると既存ユーザーの選択状態が失われるおそれがある。 -- **ボタンに `aria-pressed` を付ける:** E2E で選択状態を検証しやすくなるが、UI の変更になるため別タスクにする。 -- **seed に grade を付ける:** fps-24 と同様に PENDING とし、本番では投票または管理画面で設定する。 - -## フェーズ - -### Phase 0: seed データの準備(データのみ) - -- 問題名の一覧をユーザーから受け取る(取得元の AtCoder ページと problems.json は、エージェント環境から取得できなかった)。 -- `prisma/tasks.ts` の `fps_24_x` ブロックの直前に、fps-24 と同じく `game_x` → `game_a` の降順で 24 行を追加する(`id: 'game_a'`、`contest_id: 'game-24'`、`problem_index: 'A'`、`name`、`title: 'A. '`)。 -- 確認: `grep -c "contest_id: 'game-24'" prisma/tasks.ts` が 24 になること。 - -### Phase 1: ContestType の追加(Layer 1–3、コミット 2) - -レイヤー: DB schema / Domain types / Utilities(`src/lib/contests/`)。 - -1. `prisma/schema.prisma` の `FPS_24` の直後に `GAME_24 // 24 Problems on Combinatorial Games` を追加する。続けて `pnpm exec prisma migrate dev --name add_game_24_to_contest_type` と `pnpm exec prisma generate` を実行する。 -2. `src/lib/contests/types/contest.ts` の `FPS_24` の直後に `GAME_24` を追加する。 -3. テストを先に書く(RED)。 - - `src/lib/contests/fixtures/contest_type.ts` に `game24`(`createTestCaseForContestType('Game 24')`、`'game-24'` → `ContestType.GAME_24`)を追加する。 - - `src/lib/contests/fixtures/contest_name_labels.ts` に `game24`(`'game-24'` → `'組合せゲーム 24 題'`)を追加する。 - - `classification.test.ts` に `describe('when contest_id is game-24')` を追加する。 - - `labels/index.test.ts` に `describe('when contest_id is game-24')` を追加する。 - - `priority.test.ts` に `test('ranks GAME_24 directly below FPS_24')` を追加する。既存の `assigns a priority to every contest type` も、enum の追加によって RED になる。 -4. 実装する(GREEN)。`CONTEST_TYPES_BY_ID`、`contestTypePriorities` と JSDoc、`LABEL_GENERATORS` に `[ContestType.GAME_24, () => '組合せゲーム 24 題']` を追加する。 -5. `pnpm test:unit src/lib/contests/ src/test/lib/utils/task.test.ts` を実行する。 - -### Phase 2: Provider とグループ登録(Layer 4–5、コミット 3) - -レイヤー: Utilities(`src/features/tasks/utils/contest-table/`)と Test data。 - -1. テストを先に書く(RED)。 - - (実装時に見送り)`taskResultsForGame24Provider` フィクスチャは追加しない。既存の `taskResultsForFPS24Provider` はどのテストからも参照されておらず、Provider テストはデータをテスト内に直接書いているため(YAGNI)。 - - `game24_provider.test.ts` を `fps24_provider.test.ts` に倣って作る。テスト名は `expects to get correct metadata`、`expects to get correct display configuration`、`expects to format contest round label correctly`、`expects to filter tasks to include only game-24 contest`、`expects to generate correct table structure` など。フィルタのテストでは `fps-24` を混ぜ、除外されることを確認する。 - - `contest_table_provider_groups.test.ts` のテスト名を `dps registers EDPC, TDPC, NDPC, FPS 24, and Game 24 providers` に変え、グループ名・`buttonLabel`・`ariaLabel`・`getSize() === 5`・`getProvider(ContestType.GAME_24)` を検証する。 -2. 実装する(GREEN)。`game24_provider.ts` を作成し、`contest_table_provider_groups.ts` で import と `addProviders` の末尾への追加、JSDoc(`DP group (EDPC, TDPC, NDPC, FPS 24, and Game 24)`)の更新を行う。 -3. `pnpm test:unit src/features/tasks/utils/contest-table/` を実行する。 - -### Phase 3: E2E とドキュメント(コミット 3 に含める) - -1. `e2e/problems_contest_table.spec.ts` を新しく作る。未ログインで `/problems` を開き、`getByRole('button', { name: 'xDPC and x 24 contests' })` をクリックして、見出しが EDPC → TDPC → NDPC → `FPS 24 題` → `組合せゲーム 24 題` の順に並ぶことを確認する。テスト名は `shows Game 24 table right after FPS 24 in the xDPC group`。見出しは tasks の有無に関係なく描画されるので、seed には依存しない。 -2. `docs/guides/how-to-add-contest-table-provider.md` の単一ソース型の表に `| GAME_24 | 'game-24' | 24問 | A~X |` を追加する。task_id が `game_a` 形式であることも脚注で補足する。 -3. `pnpm test:e2e e2e/problems_contest_table.spec.ts` を実行する。 - -### Phase 4: 検証とレビュー - -1. `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`git diff --check` を実行する。 -2. 残存参照を確認する: `rg -n "xDPC・FPS 24|xDPC and FPS 24" -g '*.ts' -g '*.svelte' -g '*.md'` が `docs/dev-notes/` 以外で 0 件になること。 -3. Codex でクロスレビューする。指摘の修正は、ユーザーが選択した後に行う。 - -### リリース後の作業(人手) +- **2 PR に分割する:** 変更が小さく、コミットを分ければレビューに支障はない。 +- **ボタンに `aria-pressed` を付ける:** 選択状態を E2E で検証できるようになるが、UI の変更なので別タスクにする。 +- **E2E で問題リンク(`game_a`)まで検証する:** seed に依存するため見送った。 -マイグレーションは、main への push 時に CI の本番ジョブが自動で適用するので、手動の操作は要らない。 +## 補足 -1. CI の本番ジョブが成功したことを確認する。 -2. 管理画面 `/tasks` で AtCoder ソースから取得し、`game-24` の 24 問をインポートする(AtCoder Problems に反映されていなければ、反映を待つ)。 -3. `/problems` の「xDPC・x 24 題」で、テーブルが表示されることを確認する。 +- PostgreSQL の `ALTER TYPE ... ADD VALUE` は値を末尾に追加するので、DB 上の enum の並びは schema の並びと一致しない。過去に追加した値も同じ状態で、表示順は `contestTypePriorities` で管理しているので問題ない。 +- `src/test/lib/utils/task.test.ts` の priority の差分の期待値は、20 より前の種別しか使っていないので、今回は変更が不要だった。 +- Codex のクロスレビューでは指摘がなかった。 diff --git a/docs/guides/agent-rules/prisma-db.md b/docs/guides/agent-rules/prisma-db.md index d2b8126b9..1a0b3891f 100644 --- a/docs/guides/agent-rules/prisma-db.md +++ b/docs/guides/agent-rules/prisma-db.md @@ -13,6 +13,7 @@ paths: 1. Edit `prisma/schema.prisma` 2. Run `pnpm exec prisma migrate dev --name ` +3. Production needs no manual step: CI (`.github/workflows/ci.yml`) runs `prisma migrate deploy` on push to `main`, before deploying. Do not add a manual deploy step to plans ## Naming @@ -21,7 +22,6 @@ paths: ## Server-Only Code - Import the DB client (`$lib/server/database`) only in `src/lib/server/**` and in server-only services under `src/lib/services/**` and `src/features/**/services/**` -- Route handlers must not import the DB client directly — call service methods instead - Never import server-only code in client components ## Service Layer diff --git a/docs/guides/agent-rules/svelte-components.md b/docs/guides/agent-rules/svelte-components.md index f7093edc3..f3d5519c8 100644 --- a/docs/guides/agent-rules/svelte-components.md +++ b/docs/guides/agent-rules/svelte-components.md @@ -26,6 +26,7 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components: - Components: `PascalCase.svelte` - Stores: `snake_case.svelte.ts`; class-based with `$state()`, singleton export +- Values persisted to `localStorage` or URL params are a contract: rename display labels freely, but changing a stored key/value (e.g. preset key `dps`) silently drops users' saved state ## Props & Reactivity @@ -37,7 +38,7 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components: ## `{#each}` Patterns - Always key: `(item.id)` or `(i)` -- **Key MUST be unique per iteration** — if domain allows duplicates, use composite key (e.g. `contest_id + '-' + task_id`) +- **Key MUST be unique per iteration** — if domain allows duplicates, use composite key. Trap: `task_id` alone repeats when a task appears in multiple contests; use `contest_id + '-' + task_id` (#3460, #3442) - `(i)` is only safe when the list's order and length never change (e.g. a fixed split of a string into lines). For a mutable list — reorderable, insertable, or deletable — use a persistent unique ID or composite key; `(i)` there causes Svelte to reuse DOM/state across the wrong items after a mutation. - Filter **before**, not inside with `{#if}` - Use `{:else}` for empty lists @@ -50,8 +51,6 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components: {/each} ``` -**Common Trap:** `task_id` alone is NOT unique when same task appears in multiple contests. Use `contest_id + '-' + task_id` as composite key (see issue #3460 & PR #3442). - ## Snippets vs Components - **Snippet**: needs `$state` access, pure display, same-file; params require explicit type `{#snippet label(item: Item)}` diff --git a/docs/guides/agent-rules/testing.md b/docs/guides/agent-rules/testing.md index 85ecca23e..f26d0f821 100644 --- a/docs/guides/agent-rules/testing.md +++ b/docs/guides/agent-rules/testing.md @@ -37,7 +37,7 @@ paths: - Enum membership: `Object.hasOwn(Enum, value)`, not `in` (avoids prototype chain) - `Promise`: use `await fn()` to assert no throw, or `.resolves.toBeUndefined()`. **Never** bare `.resolves` (false-positive) - **Stubs**: parameter types must match production signature — use domain types (`TaskGrade`), not `string` -- **Test data**: realistic values (real task IDs, grade names). Extract shared fixtures to file/describe scope; inline for single-use +- **Test data**: realistic values (real task IDs, grade names). Extract shared fixtures to file/describe scope; inline for single-use. When copying a neighbor's tests, grep each fixture/helper's usages first — do not replicate unreferenced ones - **Registry exhaustiveness**: `Record` and `new Map([...])` give no compile-time enum coverage — assert it at runtime, with a named, commented exception list so deliberate gaps stay documented: `expect(Object.values(Enum).filter((key) => !REGISTRY.has(key))).toEqual(KNOWN_GAPS)` (`Record` registries: swap `REGISTRY.has(key)` for the `Object.hasOwn` form above) - **Test count as a proxy**: after a file split, compare counts (`--reporter=verbose`) — a drop means a describe block was lost in the move. A deliberate drop is valid only when mutation analysis shows detection is unchanged; record the evidence in the commit From f5ca37ef7a15bc1e80787d81016f3293f76598cd Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Tue, 6 Oct 2026 13:14:53 +0000 Subject: [PATCH 5/5] docs: remove Game 24 plan Co-Authored-By: Claude Opus 5.5 --- .../add-game-24-contest-table/plan.md | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md diff --git a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md b/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md deleted file mode 100644 index 0d95d48e4..000000000 --- a/docs/dev-notes/2026-10-06/add-game-24-contest-table/plan.md +++ /dev/null @@ -1,43 +0,0 @@ -# 組合せゲーム 24 題(game-24)の追加 - -## 概要 - -[組合せゲーム 24 題](https://atcoder.jp/contests/game-24)(contest_id `game-24`、A〜X の 24 問)を ContestType `GAME_24` として追加し、xDPC グループの「FPS 24 題」の後に表示する。あわせて、グループ名を「xDPC・FPS 24」から「xDPC・x 24 題」に改名した。 - -参考: `.agents/skills/add-contest-table-provider/`、FPS 24 追加時の #2796(インポート)と #2797(テーブル)。 - -## 前提となる事実 - -- **task_id は contest_id の数字を含まない:** `game_a`〜`game_x` であり、`fps-24` の `fps_24_a` とは形式が異なる。AtCoder の問題 URL は `getTaskUrl` が contestId と taskId を連結して作るだけで、AtCoder 側には task_id を分解する処理がないため、この形式でも影響はない。 -- **問題データの入手:** エージェント環境からは atcoder.jp を取得できず(curl は拒否され、WebFetch は失敗)、kenkoooo の problems.json も WebFetch では途中で切れて `game-24` が見つからなかった。seed の 24 行は、ユーザーが貼った一覧から作った。 -- **本番への反映:** マイグレーションは、main への push 時に CI の本番ジョブ(`.github/workflows/ci.yml`)がデプロイ前に `prisma migrate deploy` で適用するので、手動の操作は要らない。問題データは、反映後に管理画面 `/tasks` からインポートする(AtCoder Problems に game-24 が反映されている必要がある)。 - -## 決定事項 - -| 項目 | 決定 | 理由・補足 | -| ------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| 位置 | enum・priority ともに `FPS_24` の直後 | ユーザー指定。priority は 20 で、後続の種別は 1 つずつ下がる | -| 表示名 | `組合せゲーム 24 題` | ユーザー指定。「FPS 24 題」に合わせ、数字の前後に半角スペースを入れる | -| グループ名・ボタン | `xDPC・x 24 題` | ユーザー指定。aria-label は `xDPC and x 24 contests` | -| enum コメント | `24 Problems on Combinatorial Games` | `FPS_24` の英語コメントの形式に合わせる | -| プリセットキー | `dps` のまま | localStorage(`contest_table_providers`)に保存されるキーなので、変えると既存ユーザーの選択が失われる | -| Provider | `game24_provider.ts` をクラスごと複製 | EDPC / TDPC / NDPC / FPS24 の既存慣例に合わせる | -| 表示設定 | FPS 24 と同じ | 同じグループ内で見た目を揃える | -| seed の grade | 付けない | fps-24 と同様に PENDING とし、本番では投票か管理画面で設定する | -| フィクスチャ | `taskResultsForGame24Provider` は作らない | FPS 24 用の同等品がどのテストからも参照されていなかった(YAGNI) | -| E2E | ボタンを押した後の h2 見出しの並びを検証する | 見出しは tasks の有無に関係なく描画されるので、seed に依存しない | -| コミット | plan / Layer 1–3 と seed / Layer 4–5 と E2E | 1 PR・3 コミット | - -## 見送り案 - -- **`x24_providers.ts` に統合する:** `fps24_provider.ts` のリネームで差分が膨らむ。 -- **単一 contest_id 用の共通基底クラスを抽出する:** DP 系 3 クラスにも波及し、本タスクの範囲を超える。 -- **2 PR に分割する:** 変更が小さく、コミットを分ければレビューに支障はない。 -- **ボタンに `aria-pressed` を付ける:** 選択状態を E2E で検証できるようになるが、UI の変更なので別タスクにする。 -- **E2E で問題リンク(`game_a`)まで検証する:** seed に依存するため見送った。 - -## 補足 - -- PostgreSQL の `ALTER TYPE ... ADD VALUE` は値を末尾に追加するので、DB 上の enum の並びは schema の並びと一致しない。過去に追加した値も同じ状態で、表示順は `contestTypePriorities` で管理しているので問題ない。 -- `src/test/lib/utils/task.test.ts` の priority の差分の期待値は、20 より前の種別しか使っていないので、今回は変更が不要だった。 -- Codex のクロスレビューでは指摘がなかった。