From 124478ecff95dbc137d8babc783faae2a98bdf56 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 26 Apr 2026 12:28:43 +0000 Subject: [PATCH 1/3] docs/style: Update Prisma and Svelte component rules, fix TaskList Co-Authored-By: Claude Haiku 4.5 --- .claude/rules/prisma-db.md | 6 ++ .claude/rules/svelte-components.md | 3 + .../fix-grade-accordion-each-key/plan.md | 69 +++++++++++++++++++ src/lib/components/TaskList.svelte | 2 +- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md diff --git a/.claude/rules/prisma-db.md b/.claude/rules/prisma-db.md index d9a9ac420..0189c0125 100644 --- a/.claude/rules/prisma-db.md +++ b/.claude/rules/prisma-db.md @@ -141,6 +141,12 @@ When a migration leaves `finished_at = NULL` in `_prisma_migrations`: A `--rolled-back` migration is permanently skipped by `migrate deploy`; fixing the original file has no effect. +## Merged Data: Document Uniqueness Loss + +Service functions that merge records from multiple sources (e.g., `getMergedTasksMap()` combining base tasks + `ContestTaskPair` entries) lose individual field uniqueness. Document that results contain duplicates in "unique" DB fields. + +Example: `getMergedTasksMap()` returns multiple entries with same `task_id` but different `contest_id` — callers must not assume `task_id` is unique. UI layer must use composite keys (see svelte-components.md `{#each}` patterns). + ## Validate Constraints Prisma does not support `@@check`. To add one: diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md index 4569ac508..462432cea 100644 --- a/.claude/rules/svelte-components.md +++ b/.claude/rules/svelte-components.md @@ -37,6 +37,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`) - Filter **before**, not inside with `{#if}` - Use `{:else}` for empty lists @@ -48,6 +49,8 @@ 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/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md b/docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md new file mode 100644 index 000000000..3d2b1e56b --- /dev/null +++ b/docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md @@ -0,0 +1,69 @@ +# グレード別一覧で展開されないグレードの修正 + +## 概要 + +[issue #3460](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3460): 問題一覧のグレード別タブで、一部グレードのアコーディオンが展開できない不具合を修正する。 + +## 原因調査 + +### 根本原因 + +`TaskList.svelte` の `{#each}` キーに `task_id` 単独を使用しているが、`task_id` はユニークではない場合がある。 + +`getMergedTasksMap()`(`src/lib/services/tasks.ts`)は同一の `task_id` を持ちながら `contest_id` が異なるタスク(同じ問題が複数コンテストに登場するケース)を生成する。`ContestTaskPair` モデルがこのケースを管理している。 + +``` +task_id: "typical90_s" contest_id: "typical90" ← baseTaskMap +task_id: "typical90_s" contest_id: "tessoku-book" ← additionalTaskMap(ContestTaskPairで追加) +``` + +この重複した `task_id` を持つ `TaskResult` が同一グレードに含まれると、`{#each}` のキーが重複する。Svelte の keyed `{#each}` は重複キーを正しく reconcile できず、`AccordionItem` の状態が不正になって展開できないグレードが発生する。 + +### PR #3442 との対比 + +PR #3442([#3442](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/3442))は同一構造のバグを修正: + +- `TaskListForEdit.svelte` で `importTask.task_id` → `importTask.id`(真のユニークキーへ変更) + +今回も同様に、真のユニーク識別子である `contest_id + task_id` の複合キーを使用する必要がある。 + +### 証拠 + +- `getMergedTasksMap` のコメント:「Handling cases where the same problem is used in different contests」 +- `TableBodyRow` の `id={taskResult.contest_id + '-' + taskResult.task_id}`:すでに複合キーで DOM id の一意性を確保済み + +## 全 `{#each}` キーの調査結果 + +コードベース全体で `{#each ... (key)}` パターンを持つ箇所を調査した結果: + +| ファイル | キー | 判定 | +| ---------------------------------------------------- | --------------------- | ----------------------------- | +| `src/lib/components/TaskList.svelte:100` | `taskResult.task_id` | **要修正** | +| `src/lib/components/TaskListSorted.svelte:37` | `taskResult.task_id` | 安全(Mapで重複排除済み) | +| `src/routes/votes/+page.svelte:60` | `task.task_id` | 安全(DB `@unique` 制約あり) | +| `src/routes/(admin)/vote_management/+page.svelte:39` | `stat.taskId` | 安全(集計テーブルのPK) | +| その他 | `id` / enum値 / index | 安全 | + +**`TaskListSorted.svelte`** は `getTaskResultsOnlyResultExists()` が Map(`task_id` キー)で重複排除するため現時点では安全。ただし `getMergedTasksMap` を使うデータソースに変わった場合は壊れる可能性あり。 + +## 設計の判断 + +修正対象は `TaskList.svelte` のみ。`TableBodyRow` の `id` 属性が既に `contest_id + '-' + task_id` を使用しているため、同じセパレーターで統一する。 + +## 修正内容 + +**対象ファイル:** `src/lib/components/TaskList.svelte` (line 100) + +```svelte + +{#each taskResults as taskResult (taskResult.task_id)} + + +{#each taskResults as taskResult (taskResult.contest_id + '-' + taskResult.task_id)} +``` + +## 検証方法 + +1. `pnpm test:unit` — ユニットテストの確認 +2. `pnpm dev` でローカルサーバーを起動し `/problems` → グレード別タブで全グレードが展開できることを確認 +3. 複数コンテストに登場する問題を含むグレード(例:典型90問、鉄則本)で特に確認 diff --git a/src/lib/components/TaskList.svelte b/src/lib/components/TaskList.svelte index bbfa66303..3cf080f56 100644 --- a/src/lib/components/TaskList.svelte +++ b/src/lib/components/TaskList.svelte @@ -97,7 +97,7 @@ - {#each taskResults as taskResult (taskResult.task_id)} + {#each taskResults as taskResult (taskResult.contest_id + '-' + taskResult.task_id)} Date: Sun, 26 Apr 2026 12:31:36 +0000 Subject: [PATCH 2/3] chore: remove old plan (#3460) --- .../fix-grade-accordion-each-key/plan.md | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md diff --git a/docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md b/docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md deleted file mode 100644 index 3d2b1e56b..000000000 --- a/docs/dev-notes/2026-04-26/fix-grade-accordion-each-key/plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# グレード別一覧で展開されないグレードの修正 - -## 概要 - -[issue #3460](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3460): 問題一覧のグレード別タブで、一部グレードのアコーディオンが展開できない不具合を修正する。 - -## 原因調査 - -### 根本原因 - -`TaskList.svelte` の `{#each}` キーに `task_id` 単独を使用しているが、`task_id` はユニークではない場合がある。 - -`getMergedTasksMap()`(`src/lib/services/tasks.ts`)は同一の `task_id` を持ちながら `contest_id` が異なるタスク(同じ問題が複数コンテストに登場するケース)を生成する。`ContestTaskPair` モデルがこのケースを管理している。 - -``` -task_id: "typical90_s" contest_id: "typical90" ← baseTaskMap -task_id: "typical90_s" contest_id: "tessoku-book" ← additionalTaskMap(ContestTaskPairで追加) -``` - -この重複した `task_id` を持つ `TaskResult` が同一グレードに含まれると、`{#each}` のキーが重複する。Svelte の keyed `{#each}` は重複キーを正しく reconcile できず、`AccordionItem` の状態が不正になって展開できないグレードが発生する。 - -### PR #3442 との対比 - -PR #3442([#3442](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/3442))は同一構造のバグを修正: - -- `TaskListForEdit.svelte` で `importTask.task_id` → `importTask.id`(真のユニークキーへ変更) - -今回も同様に、真のユニーク識別子である `contest_id + task_id` の複合キーを使用する必要がある。 - -### 証拠 - -- `getMergedTasksMap` のコメント:「Handling cases where the same problem is used in different contests」 -- `TableBodyRow` の `id={taskResult.contest_id + '-' + taskResult.task_id}`:すでに複合キーで DOM id の一意性を確保済み - -## 全 `{#each}` キーの調査結果 - -コードベース全体で `{#each ... (key)}` パターンを持つ箇所を調査した結果: - -| ファイル | キー | 判定 | -| ---------------------------------------------------- | --------------------- | ----------------------------- | -| `src/lib/components/TaskList.svelte:100` | `taskResult.task_id` | **要修正** | -| `src/lib/components/TaskListSorted.svelte:37` | `taskResult.task_id` | 安全(Mapで重複排除済み) | -| `src/routes/votes/+page.svelte:60` | `task.task_id` | 安全(DB `@unique` 制約あり) | -| `src/routes/(admin)/vote_management/+page.svelte:39` | `stat.taskId` | 安全(集計テーブルのPK) | -| その他 | `id` / enum値 / index | 安全 | - -**`TaskListSorted.svelte`** は `getTaskResultsOnlyResultExists()` が Map(`task_id` キー)で重複排除するため現時点では安全。ただし `getMergedTasksMap` を使うデータソースに変わった場合は壊れる可能性あり。 - -## 設計の判断 - -修正対象は `TaskList.svelte` のみ。`TableBodyRow` の `id` 属性が既に `contest_id + '-' + task_id` を使用しているため、同じセパレーターで統一する。 - -## 修正内容 - -**対象ファイル:** `src/lib/components/TaskList.svelte` (line 100) - -```svelte - -{#each taskResults as taskResult (taskResult.task_id)} - - -{#each taskResults as taskResult (taskResult.contest_id + '-' + taskResult.task_id)} -``` - -## 検証方法 - -1. `pnpm test:unit` — ユニットテストの確認 -2. `pnpm dev` でローカルサーバーを起動し `/problems` → グレード別タブで全グレードが展開できることを確認 -3. 複数コンテストに登場する問題を含むグレード(例:典型90問、鉄則本)で特に確認 From ed88fc87765ea69e8267d2e522855d3ac5d28bee Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 26 Apr 2026 12:34:23 +0000 Subject: [PATCH 3/3] style: Fix composite key example in Svelte each pattern Use string concatenation syntax in composite key example. Co-Authored-By: Claude Haiku 4.5 --- .claude/rules/svelte-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md index 462432cea..bcb24da19 100644 --- a/.claude/rules/svelte-components.md +++ b/.claude/rules/svelte-components.md @@ -37,7 +37,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 (e.g. `contest_id + '-' + task_id`) - Filter **before**, not inside with `{#if}` - Use `{:else}` for empty lists