From da66b4351f2c9ae65109472a16bc9461ecfa7ba9 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 26 Oct 2026 12:21:28 +0000 Subject: [PATCH 1/3] docs: consolidate the contest task pair notes into a guide Co-Authored-By: Claude Opus 5.5 --- .../contest-task-mapping/initial_plan.md | 237 ---- .../contest-task-pair-mapping/plan.md | 264 ---- .../add-contest-task-pairs-to-seeds/plan.md | 279 ----- .../refactor-getMergedTasksMap/lesson.md | 184 --- .../impact-analysis/future_tasks.md | 483 -------- .../impact-analysis/phase1-findings.md | 207 ---- .../impact-analysis/phase2-analysis.md | 458 ------- .../2025-10-26/impact-analysis/plan.md | 1100 ----------------- .../2025-10-26/impact-analysis/report.md | 781 ------------ .../plan.md | 115 -- .../how-to-add-contest-table-provider.md | 2 +- docs/guides/how-to-add-contest-task-pairs.md | 109 ++ 12 files changed, 110 insertions(+), 4109 deletions(-) delete mode 100644 docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md delete mode 100644 docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md delete mode 100644 docs/dev-notes/2025-10-22/add-contest-task-pairs-to-seeds/plan.md delete mode 100644 docs/dev-notes/2025-10-25/refactor-getMergedTasksMap/lesson.md delete mode 100644 docs/dev-notes/2025-10-26/impact-analysis/future_tasks.md delete mode 100644 docs/dev-notes/2025-10-26/impact-analysis/phase1-findings.md delete mode 100644 docs/dev-notes/2025-10-26/impact-analysis/phase2-analysis.md delete mode 100644 docs/dev-notes/2025-10-26/impact-analysis/plan.md delete mode 100644 docs/dev-notes/2025-10-26/impact-analysis/report.md delete mode 100644 docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md create mode 100644 docs/guides/how-to-add-contest-task-pairs.md diff --git a/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md b/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md deleted file mode 100644 index 96b4c6ce2..000000000 --- a/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md +++ /dev/null @@ -1,237 +0,0 @@ -# Contest-Problem Mapping Extension Plan - -## 概要 - -既存のTask テーブルのunique制約を維持しながら、複数コンテストで同一問題を扱うための拡張方針を検討。AtCoder Problems の実装を参考に、段階的な移行戦略を策定。 - -## 現状の課題 - -### 課題1: Task識別の制限 - -- **問題**: `task_id` が同じでも `contest_id` が異なるケースが多数存在 -- **制約**: Task テーブルの `task_id` unique制約は必ず維持する必要がある -- **影響**: AtCoder Problems API の Problems API で登録済みの (task_id, contest_id) の問題のみ処理可能 - -### 課題2: 影響範囲の調査 - -- 数万行のTypeScript・Svelteコード -- `map` 形式の処理が広範囲に存在 -- e2eテストがほぼ実装されていないので、手動での確認が必要 - -## 解決方針 - -### 基本アプローチ - -AtCoder Problems API を活用: - -- 問題情報: 同一の `task_id` が存在する場合は、どれか一つのコンテストに割り当て - - `https://kenkoooo.com/atcoder/resources/problems.json` -- コンテストと問題idの対応関係を網羅 - - `https://kenkoooo.com/atcoder/resources/contest-problem.json` - -### 技術的解決策 - -#### 1. 複合キーによる識別 - -**方針**: `map, TaskResult>` への移行 - -```typescript -// 型定義 -type TaskKey = `${string}:${string}`; // "contest_id:task_id" - -// ヘルパー関数 -function createTaskKey(contestId: string, taskId: string): TaskKey { - return `${contestId}:${taskId}`; -} - -// マップの型 -type TaskResultMap = Map; -``` - -**利点**: - -- 文字列連結は一般的で高速 -- TypeScriptのTuple型も利用可能だが、文字列の方が実用的 -- O(1)での参照が可能 - -#### 2. データマイグレーション戦略 - -```typescript -// 既存データの移行 -function migrateExistingData(oldMap: Map): Map { - const newMap = new Map(); - - for (const [taskId, result] of oldMap) { - const primaryContestId = result.contest_id || 'primary'; - const key = createTaskKey(primaryContestId, taskId); - newMap.set(key, result); - } - - return newMap; -} - -// 互換性レイヤー -export class TaskMapAdapter { - constructor( - private enhanced: EnhancedTaskMap, - private legacy: LegacyTaskMap, - ) {} - - get(taskId: string, contestId?: string): TaskResult | undefined { - if (contestId) { - return this.enhanced.get(createTaskKey(contestId, taskId)); - } - return this.legacy.get(taskId); - } -} -``` - -#### 3. インポート処理の分離 - -**方針**: 問題データインポートとマッピングデータインポートを別ページで処理 - -```typescript -interface ImportOptions { - includeContestMapping?: boolean; // contest-problem.json を使用 - onlyProblems?: boolean; // problems.json のみ使用 -} - -// 問題データインポート(既存制約を守る) -async function importTaskData(options: ImportOptions = {}) { - if (options.onlyProblems !== false) { - await importFromProblemsJson(); - } - - if (options.includeContestMapping) { - await importContestProblemMapping(); - } -} -``` - -**分離理由**: - -- 責務の分離(異なるAPIエンドポイント) -- エラーハンドリングの違い -- 将来のコンテストサイト固有設定への対応 - -## 実装計画 - -### Phase 1: 基盤整備 - -- [ ] 新しい型定義の作成 -- [ ] ユーティリティ関数の実装 -- [ ] 互換性レイヤーの作成 - -### Phase 2: 影響範囲調査 - -```bash -# 調査用コマンド -grep -r "taskId" src/ --include="*.ts" --include="*.svelte" -grep -r "Map; - previewMappingChanges(): Promise; - validateMappingData(): Promise; -} - -interface ImportFilter { - contestIds?: string[]; - taskIds?: string[]; - dateRange?: { from: Date; to: Date }; -} -``` - -**利点**: - -- 長期的な保守性 -- エラーハンドリングの容易さ -- 他コンテストサイト対応への拡張性 - -### ディレクトリ構成案 - -``` -src/routes/admin/import/ -├── problems/ # 問題データインポート -│ ├── +page.svelte -│ └── types.ts -├── mapping/ # コンテスト-問題マッピング -│ ├── +page.svelte -│ ├── bulk/ -│ └── selective/ -└── shared/ # 共通コンポーネント - ├── ImportStatus.svelte - └── ValidationResults.svelte -``` - -## 今後の課題 - -### 短期 - -- [ ] 影響範囲の詳細調査 -- [ ] プロトタイプの作成 -- [ ] 基本的なe2eテストの追加 - -### 中期 - -- [ ] 他コンテストサイトAPI対応 -- [ ] APIレート制限への対応 -- [ ] UI/UXの改善 - -### 長期 - -- [ ] 完全自動化システム -- [ ] 包括的なテストスイート -- [ ] パフォーマンス最適化 - -## リスク要因と対策 - -| リスク | 影響度 | 対策 | -| -------------------- | ------ | -------------------- | -| 既存機能の破綻 | 高 | 互換性レイヤーの実装 | -| データ不整合 | 中 | バリデーション強化 | -| パフォーマンス劣化 | 中 | 段階的な最適化 | -| メンテナンスコスト増 | 中 | ドキュメント整備 | - -## 決定事項 - -1. **Task テーブルのunique制約は維持** -2. **複合キー (`contest_id:task_id`) による識別** -3. **段階的移行による安全な実装** -4. **インポート機能の責務分離** -5. **Web UI による管理画面実装** - -## 参考リンク - -- [API client in AtCoder Problems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/api/APIClient.ts#L239) -- [Problems API](https://kenkoooo.com/atcoder/resources/problems.json) -- [Contest-Problem API](https://kenkoooo.com/atcoder/resources/contest-problem.json) - ---- - -**作成日**: 2025-09-17 -**最終更新**: 2025-09-17 -**ステータス**: 計画段階 diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md deleted file mode 100644 index 919f0c41c..000000000 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ /dev/null @@ -1,264 +0,0 @@ -# Contest-Task Pair Mapping 実装計画 - -## 概要 - -既存の Task テーブルの unique 制約を維持しながら、複数コンテストで同一問題を扱うための拡張実装計画。[前回計画](../../2025-09-17/contest-task-mapping/initial_plan.md)をベースに、互換性レイヤを用いた段階的移行戦略のうちデータベース設計と型定義を詳細化。 - -## 1. 命名の決定 - -- **モデル名**: `ContestTaskPair` (「ペア」であることを明示) -- **型名**: `ContestTaskPairKey` (Prisma モデルとの衝突回避) -- **マップ型**: `TaskResultMapByContestTaskPair` (キーと値の関係を明示) - -## 2. データベース設計 - -### Prisma スキーマ追加 - -```prisma -model ContestTaskPair { - id String @id @default(uuid()) - contestId String - taskTableIndex String - taskId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([contestId, taskId]) // ペアの重複を防止 - @@index([contestId]) // contestId 検索を高速化 - @@map("contesttaskpair") -} -``` - -**設計判断**: - -- id 属性追加: 将来のリレーション対応・一貫性のため -- 複合一意制約: ペアレベルでの重複防止 -- インデックス: 後から追加可能だが contestId 検索の高速化 - -### マイグレーション - -```bash -# 開発環境 -pnpm dlx prisma migrate dev --name create_contest_task_pair -``` - -### CRUD メソッドの実装 - -**ファイル**: `src/lib/services/contest_task_pairs.ts` - -```typescript -import { Prisma } from '@prisma/client'; - -import { default as db } from '$lib/server/database'; -import type { - ContestTaskPair, - ContestTaskPairs, - ContestTaskPairCreate, - ContestTaskPairUpdate, -} from '$lib/types/contest_task_pair'; - -/** - * 単一のContestTaskPairを取得 - */ -export async function getContestTaskPair( - contestId: string, - taskId: string, -): Promise { - const contestTaskPair = await db.contestTaskPair.findUnique({ - where: { - contestId_taskId: { - contestId, - taskId, - }, - }, - }); - - return contestTaskPair; -} - -/** - * ContestTaskPair の一覧を取得 - */ -export async function getContestTaskPairs(): Promise { - return await db.contestTaskPair.findMany(); -} - -/** - * ContestTaskPair の新規レコードを作成 - */ -export async function createContestTaskPair( - params: ContestTaskPairCreate, -): Promise { - const { contestId, taskTableIndex, taskId } = params; - - try { - const contestTaskPair = await db.contestTaskPair.create({ - data: { - contestId, - taskTableIndex, - taskId, - }, - }); - - console.log('Created ContestTaskPair:', contestTaskPair); - - return contestTaskPair; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { - console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); - const existingPair = await getContestTaskPair(contestId, taskId); - - if (!existingPair) { - throw new Error('Unexpected: record exists but cannot be fetched'); - } - - return existingPair; - } - - console.error('Failed to create ContestTaskPair:', error); - throw error; - } -} - -/** - * ContestTaskPair のレコードを更新 - */ -export async function updateContestTaskPair( - params: ContestTaskPairUpdate, -): Promise { - const { contestId, taskTableIndex, taskId } = params; - - try { - const updatedContestTaskPair = await db.contestTaskPair.update({ - where: { - contestId_taskId: { - contestId, - taskId, - }, - }, - data: { - taskTableIndex, - }, - }); - - console.log('Updated ContestTaskPair:', updatedContestTaskPair); - - return updatedContestTaskPair; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; - console.error(errorMessage); - throw new Error(errorMessage, { cause: error as Error }); - } - - console.error('Failed to update ContestTaskPair:', error); - throw error; - } -} -``` - -**特徴**: - -- DRY原則に従い `getContestTaskPair()` を共通メソッドとして切り出し -- 複合ユニーク制約 `@@unique([contestId, taskId])` を活用 -- Prisma のエラーハンドリング(例: `P2002`, `P2025`)を活用して、事前チェックを省略し、TOCTOU (Time-of-Check to Time-of-Use)競合を回避 -- Prisma の自動生成型を使用して型安全性を確保 - -## 3. 型定義の更新 - -### Prisma型に基づく型定義 - -**ファイル**: `src/lib/types/contest_task_pair.ts` - -```typescript -import type { ContestTaskPair as ContestTaskPairOrigin } from '@prisma/client'; - -export type ContestTaskPair = ContestTaskPairOrigin; - -// Prismaの自動生成型をベースとした配列型 -export type ContestTaskPairs = ContestTaskPair[]; - -// CRUD操作用のパラメータ型 -export type ContestTaskPairCreate = { - contestId: string; - taskTableIndex: string; - taskId: string; -}; - -export type ContestTaskPairRead = ContestTaskPairCreate; - -export type ContestTaskPairUpdate = ContestTaskPairCreate; -``` - -### マッピング用の型定義 - -```typescript -import type { TaskResult } from '$lib/types/task_result'; - -// 型定義 -export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" - -// ヘルパー関数 -export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { - return `${contestId}:${taskId}`; -} - -// マップの型(明示的) -export type TaskResultMapByContestTaskPair = Map; -``` - -**設計判断**: - -- Prisma の自動生成型を活用することで型安全性を確保 -- クライアントサイドでも Prisma 型を使用可能(型定義のみでランタイムコード不要) -- CRUD パラメータ型を定義して、メソッド引数の型安全性を向上 - -## 4. 実行計画 - -- [✅] Prisma スキーマに ContestTaskPair モデル追加 -- [✅] マイグレーション実行 -- [✅] CRUD メソッドを追加 -- [✅] TypeScript 型定義追加 - -## 決定事項 - -1. **モデル名**: ContestTaskPair -2. **型名**: ContestTaskPairKey, TaskResultMapByContestTaskPair - -## 今後の課題 - -1. **移行方針**: TaskMapAdapter による段階的移行 -2. **アダプタ配置**: `src/lib/utils/task_results.ts`(影響局所化・テスト容易性・削除容易性) -3. **id 属性**: 将来性を考慮して追加 -4. **Feature Flag**: 環境変数制御、getTaskResults()のみ適用(段階1) -5. **テスト方針**: `test` + `expect to` + TypeScript strict + assert追加 -6. **監視手法**: console.log + Vercelログ(APM導入見送り) -7. **データ投入**: seed.ts優先 → 管理者画面は、その次の段階で実装 - ---- - -## 教訓と一般化 - -以下は、実装中に得られた教訓を一般化したものです。今後の開発においても参考にしてください。 - -### 1. 防御的なチェックの再考 - -- **教訓**: Prisma のエラーハンドリング(例: `P2002` や `P2025`)を活用することで、事前チェックを省略し、TOCTOU(Time-of-Check to Time-of-Use)競合を回避できる。 -- **推奨**: 事前チェックを行う代わりに、データベース操作の結果を直接利用し、エラーを適切に処理する。 - -### 2. 戻り値の活用 - -- **教訓**: CRUD メソッドで作成・更新されたレコードを返すことで、呼び出し元が追加のクエリを実行せずに結果を利用できる。 -- **推奨**: `create` や `update` メソッドでは、`void` を返すのではなく、作成・更新されたレコードを返すようにする。 - -### 3. 型定義の明確化 - -- **教訓**: Prisma の自動生成型を活用しつつ、必要に応じて独自の型を定義することで、型安全性と可読性を向上できる。 -- **推奨**: CRUD 操作用の型(例: `Create`, `Update`, `Read`)を明確に分離し、それぞれの目的に応じた型を定義する。 - -### 4. ログとエラーハンドリング - -- **教訓**: エラー発生時には、適切なログを出力し、問題の特定を容易にする。 -- **推奨**: Prisma のエラーコード(例: `P2002`, `P2025`)を活用して、エラー内容に応じた具体的なメッセージを出力する。 - -**更新日**: 2025-10-15 diff --git a/docs/dev-notes/2025-10-22/add-contest-task-pairs-to-seeds/plan.md b/docs/dev-notes/2025-10-22/add-contest-task-pairs-to-seeds/plan.md deleted file mode 100644 index d5913e548..000000000 --- a/docs/dev-notes/2025-10-22/add-contest-task-pairs-to-seeds/plan.md +++ /dev/null @@ -1,279 +0,0 @@ -# contest_task_pairs データ投入処理の実装 - -## 概要 - -`prisma/seed.ts` に `prisma/contest_task_pairs.ts` のデータ投入処理を追加します。 - -`addTasks()` と `addTask()` の実装パターンを参考に、`addContestTaskPairs()` と `addContestTaskPair()` を実装しています。 - -## 追加内容 - -### 1. インポート追加 - -`.fabbrica` から `defineContestTaskPairFactory` をインポート: - -```typescript -import { - initialize, - defineContestTaskPairFactory, - defineUserFactory, - defineKeyFactory, - defineTaskFactory, - defineTagFactory, - defineTaskTagFactory, - defineTaskAnswerFactory, - defineSubmissionStatusFactory, - defineWorkBookFactory, -} from './.fabbrica'; -``` - -`contest_task_pairs` データをインポート: - -```typescript -import { contest_task_pairs } from './contest_task_pairs'; -``` - -### 2. 並行処理設定追加 - -`QUEUE_CONCURRENCY` に `contestTaskPairs` を追加: - -```typescript -const QUEUE_CONCURRENCY = { - users: Number(process.env.SEED_USERS_CONCURRENCY) || 2, - tasks: Number(process.env.SEED_TASKS_CONCURRENCY) || 3, - contestTaskPairs: Number(process.env.SEED_CONTEST_TASK_PAIRS_CONCURRENCY) || 2, - tags: Number(process.env.SEED_TAGS_CONCURRENCY) || 2, - taskTags: Number(process.env.SEED_TASK_TAGS_CONCURRENCY) || 2, - submissionStatuses: Number(process.env.SEED_SUBMISSION_STATUSES_CONCURRENCY) || 2, - answers: Number(process.env.SEED_ANSWERS_CONCURRENCY) || 2, -} as const; -``` - -### 3. main 関数に処理追加 - -```typescript -async function main() { - try { - console.log('Seeding has been started.'); - - await addUsers(); - await addTasks(); - await addContestTaskPairs(); - await addWorkBooks(); - await addTags(); - await addTaskTags(); - await addSubmissionStatuses(); - await addAnswers(); - - console.log('Seeding has been completed.'); - } catch (e) { - console.error('Failed to seed:', e); - throw e; - } -} -``` - -### 4. 投入処理関数追加 - -#### `addContestTaskPairs()` 関数 - -```typescript -async function addContestTaskPairs() { - console.log('Start adding contest task pairs...'); - - const contestTaskPairFactory = defineContestTaskPairFactory(); - - // Create a queue with limited concurrency for contest task pair operations - const contestTaskPairQueue = new PQueue({ concurrency: QUEUE_CONCURRENCY.contestTaskPairs }); - - for (const pair of contest_task_pairs) { - contestTaskPairQueue.add(async () => { - try { - const [registeredPair, registeredTask] = await Promise.all([ - prisma.contestTaskPair.findUnique({ - where: { - contestId_taskId: { - contestId: pair.contest_id, - taskId: pair.problem_id, - }, - }, - }), - prisma.task.findUnique({ - where: { task_id: pair.problem_id }, - }), - ]); - - if (!registeredTask) { - console.warn( - 'Skipped contest task pair due to missing task:', - pair.problem_id, - 'for contest', - pair.contest_id, - 'index', - pair.problem_index, - ); - } else if (!registeredPair) { - await addContestTaskPair(pair, contestTaskPairFactory); - console.log( - 'contest_id:', - pair.contest_id, - 'problem_index:', - pair.problem_index, - 'task_id:', - pair.task_id, - 'was registered.', - ); - } - } catch (e) { - console.error('Failed to add contest task pair', pair, e); - } - }); - } - - await contestTaskPairQueue.onIdle(); // Wait for all contest task pairs to complete - console.log('Finished adding contest task pairs.'); -} -``` - -#### `addContestTaskPair()` 関数 - -```typescript -async function addContestTaskPair( - pairs: (typeof contest_task_pairs)[number], - contestTaskPairFactory: ReturnType, -) { - await contestTaskPairFactory.create({ - contestId: pairs.contest_id, - taskTableIndex: pairs.problem_index, - taskId: pairs.task_id, - }); -} -``` - -## 実装パターン - -`addTasks()` / `addTask()` と同じパターンを採用: - -- **重複チェック**:`findUnique()` で既存データをチェック -- **並行処理**:`PQueue` を使用した並行処理制御 -- **エラーハンドリング**:try-catch で例外処理 -- **ログ出力**:処理開始・完了・エラーをログ出力 - -## contest_task_pairs データ構造 - -```typescript -{ - contest_id: string; // コンテストID(例:'tessoku-book') - task_id: string; // タスクID(例:'typical90_s') - problem_index: string; // 問題インデックス(例:'C18') -} -``` - -## 実行方法 - -```bash -pnpm db:seed -``` - -通常のシード実行で `addContestTaskPairs()` が呼び出されます。 - -## 環境変数による並行数調整 - -```bash -SEED_CONTEST_TASK_PAIRS_CONCURRENCY=4 pnpm db:seed -``` - -## 実装完了 - -2025-10-22 に実装完了。合計 13 個の `ContestTaskPair` レコードが正常に投入されました。 - -## 教訓と抽象化 - -### 1. ファクトリ再生成の必要性 - -**問題**: Prisma スキーマに新しいモデルを追加しても、`.fabbrica` に自動生成されない場合がある。 - -**原因**: スキーマ変更後に `prisma generate` を実行する必要があります。 - -**解決策**: - -```bash -pnpm prisma generate -``` - -このコマンドにより、新しいモデル用のファクトリが生成されます。 - -### 2. 既存パターンの活用による効率化 - -**パターン**: データ投入処理の実装パターンは統一する。 - -**利点**: - -- コードの一貫性が保たれる -- デバッグやメンテナンスが容易 -- 新しい開発者の理解が速い - -**実装パターン** (`addTasks()` と同じ): - -1. ファクトリをインスタンス化 -2. `PQueue` で並行処理制御 -3. `findUnique()` で重複チェック -4. キューが空になるまで待機 -5. 処理結果をログ出力 - -### 3. データ構造の名前の統一性 - -**注意点**: `contest_task_pairs.ts` ファイルのフィールド名が `problem_id` ですが、Prisma スキーマでは `taskId` です。 - -**推奨**: データファイルとスキーマのフィールド名を統一する、または明確なマッピングを文書化する。 - -**現在の対応**: - -```typescript -// contest_task_pairs.ts から読み込まれるデータ -{ - contest_id: 'tessoku-book', - problem_id: 'typical90_s', // ← 注意:problem_id - problem_index: 'C18' -} - -// Prisma への投入時にマッピング -contestId: pair.contest_id, -taskId: pair.problem_id, // ← problem_id を taskId に -taskTableIndex: pair.problem_index -``` - -### 4. 処理順序の設計 - -**重要**: `addContestTaskPairs()` は `addTasks()` の後に実行する。 - -**理由**: `ContestTaskPair` は `taskId` を参照します。外部キー制約により、参照先が存在する必要があります。 - -**処理順序**: - -1. `addUsers()` - ユーザー作成 -2. `addTasks()` - タスク作成 ⭐ 先 -3. `addContestTaskPairs()` - コンテスト-タスク ペア ⭐ 後 -4. `addWorkBooks()` - ワークブック作成 - -### 5. 環境変数による動的調整 - -**利点**: 環境に応じて並行処理数を調整可能。 - -**フォールバック**: デフォルト値を用意することで、環境変数が設定されていない場合も動作します。 - -```typescript -const QUEUE_CONCURRENCY = { - contestTaskPairs: Number(process.env.SEED_CONTEST_TASK_PAIRS_CONCURRENCY) || 2, -}; -``` - -### 6. ログ出力の重要性 - -**ポイント**: - -- 処理開始・完了ログで全体的な進捗を把握 -- エラー発生時は詳細をログ出力 -- 既存データとの重複は警告またはスキップログを出力 - -**効果**: トレーニング・デバッグ時の問題特定が容易 diff --git a/docs/dev-notes/2025-10-25/refactor-getMergedTasksMap/lesson.md b/docs/dev-notes/2025-10-25/refactor-getMergedTasksMap/lesson.md deleted file mode 100644 index 20cb9bc8c..000000000 --- a/docs/dev-notes/2025-10-25/refactor-getMergedTasksMap/lesson.md +++ /dev/null @@ -1,184 +0,0 @@ -# getMergedTasksMap のリファクタリング教訓 - -## 学習内容 - -### 1. **参照 vs コピー** - -TypeScript(JavaScript)の `const newTask = task;` は参照をコピーするため、`newTask` を変更すると元の `task` も変更されます。 - -- **浅いコピー**: `const newTask = { ...task };` -- **深いコピー**: `const newTask = JSON.parse(JSON.stringify(task));` - -### 2. **TypeScript らしいコード書き方** - -- `map()` で初期化: `new Map(tasks.map(task => [task.task_id, task]))` -- ループではなく関数型メソッド (`filter()`, `map()`, `flatMap()`) -- スプレッド演算子で Map をマージ: `new Map([...map1, ...map2])` - -### 3. **`flatMap()` vs `map()`** - -`flatMap()` は返した配列を1段階フラット化するため、条件付きの可変長結果に最適: - -```typescript -// flatMap で条件分岐を自然に表現 -.flatMap((pair) => { - if (!task || !contestType) return []; - return [createMergedTask(...)]; -}); -// 結果: 該当する要素だけが含まれる -``` - -### 4. **読みやすさ > 1行でまとめる** - -無理やり `return` や1行で書く必要はない: - -- 複雑な条件は `if` 文で早期リターン -- オブジェクト生成は `key` と `value` を分けて記述 -- 難しいロジックはヘルパー関数に抽出 - -### 5. **計算量の分析** - -- 全体: **O(N + M)** (N: タスク数、M: ペア数) -- `Map.has()`, `Map.get()` は **O(1)** なのでループ内で複数回呼んでもOK - -### 6. **ドキュメント化のポイント** - -- 関数の目的と副作用を明確に -- **計算量と根拠** を記載 -- **使用例** を `@example` で示す -- 戻り値の構造を詳しく説明 - -## コード例(改善版) - -src/lib/services/tasks.ts を参照 - -## キーポイント - -- ✅ 非破壊的な変更(スプレッド演算子) -- ✅ 関数型パラダイム(`filter()`, `flatMap()` 使用) -- ✅ 早期リターンで複雑さを減らす -- ✅ ヘルパー関数で責任分離 -- ✅ 明確なドキュメント化 - ---- - -# createContestTaskPairKey のテスト設計教訓 - -## テスト作成で学んだこと - -### 1. **ヘルパー関数で重複削減** - -同じパターンのテストコードは **ヘルパー関数** に抽出: - -```typescript -// ❌ Before: 重複が多い -const key1 = createTestKey(pair); -const key2 = createTestKey(pair); -expect(key1).toBe(key2); - -// ✅ After: ヘルパー関数化 -const expectKeysToBeConsistent = (pair: TestPair): void => { - const key1 = createTestKey(pair); - const key2 = createTestKey(pair); - expect(key1).toBe(key2); -}; -expectKeysToBeConsistent(pair); -``` - -### 2. **パラメタライズテスト(test.each)で 4 個 → 1 個に** - -4 つの似たテストは `test.each()` で 1 つにまとめる: - -```typescript -// ❌ Before: 4 つのテスト関数 -test('expects empty contest_id to throw an error', () => { ... }); -test('expects empty task_id to throw an error', () => { ... }); -test('expects whitespace-only contest_id to throw an error', () => { ... }); -test('expects whitespace-only task_id to throw an error', () => { ... }); - -// ✅ After: 1 つのパラメタライズテスト -test.each<[string, string, string]>([ - ['', 'abc123_a', 'contestId must be a non-empty string'], - [' ', 'abc123_a', 'contestId must be a non-empty string'], - ['abc123', '', 'taskId must be a non-empty string'], - ['abc123', ' ', 'taskId must be a non-empty string'], -])('expects error when contest_id="%s" and task_id="%s"', (contestId, taskId, expectedError) => { - expect(() => createContestTaskPairKey(contestId, taskId)).toThrow(expectedError); -}); -``` - -### 3. **テストデータを集約して保守性向上** - -テストデータを `pairs` オブジェクトで一元管理: - -```typescript -const pairs = { - normal: [...], // 正常系ケース - edge: [...], // エッジケース - anomaly: [...], // 異常系ケース -}; -``` - -### 4. **テストカバレッジの考え方** - -- **正常系**: 期待通りに動くか -- **エッジケース**: 空文字列、ホワイトスペース、長い文字列 -- **異常系**: 特殊文字、Unicode、改行、タブ -- **キー検証**: フォーマット、一意性、可逆性 - -### 5. **べストプラクティス** - -| 改善内容 | 効果 | -| -------------------- | -------------------- | -| ヘルパー関数化 | コード重複 -40% | -| パラメタライズテスト | テスト関数数 削減 | -| テストデータ集約 | 保守性向上 | -| beforeEach で初期化 | テスト間の独立性確保 | - -### 6. **コード レビュー フィードバック対応** - -#### 指摘事項 - -| 項目 | 内容 | 対応 | -| ------------------ | ------------------------------------ | ------------------ | -| 弱い Assertion | `toContain()` では不正確 | `toBe()` に変更 | -| 冗長テスト | O(n²) の全ペア比較テストは不要 | O(n) Set検証に統一 | -| beforeEach削減 | イミュータブルデータの不要なコピー | 削除 | -| 特殊文字カバレッジ | **デリミタ文字(コロン)が未テスト** | 3ケース追加 | - -#### 学んだことと根拠 - -- **アサーション強度**: `toContain()` は部分一致で誤検知の可能性 → `toBe()` で完全一致を保証 -- **テスト効率**: 冗長な検証は実装と同じ複雑さ → 代表的パターンだけ実装 -- **パーサビリティ脆弱性**: デリミタ文字(`:`)が ID に含まれると `split(':')` で分割失敗 → **デリミタ自体のテストケースが必須** - -#### 対応結果 - -- ✅ Assertion を 4 個改善(`toContain()` → `toBe()` 統一) -- ✅ 冗長テスト 1 個削除(O(n²) → O(n)) -- ✅ コロン文字テスト 3 個追加(`contestId` のみ、`taskId` のみ、両方) -- ✅ **テスト総数: 26 → 29 個**(全成功 ✅) - -#### 重要な発見 - -**コロン文字は関数内で保存されるが、デリミタと同じため使用時に注意が必要** - -```typescript -// 実装例:コロンを含む ID -const key = createContestTaskPairKey('abc:123', 'task_a'); -// 結果: "abc:123:task_a" (コロン3個) - -// ⚠️ split(':') での分割に注意 -const [contestId, ...taskIdParts] = key.split(':'); -// contestId = 'abc', taskIdParts = ['123', 'task_a'] ❌ 失敗! -``` - -**教訓**: デリミタ文字も含めてテストし、実装側で検証ルールを明確にすべき。 - -## テスト統計 - -- **総テスト数**: 29 個(全成功 ✅) -- **パラメタライズテスト**: 2 グループ(合計 11 ケース) -- **ヘルパー関数**: 5 個 -- **テストデータセット**: 3 グループ(normal, edge, anomaly) -- **特殊文字カバレッジ**: パイプ 4 ケース + コロン 3 ケース + その他 8 ケース diff --git a/docs/dev-notes/2025-10-26/impact-analysis/future_tasks.md b/docs/dev-notes/2025-10-26/impact-analysis/future_tasks.md deleted file mode 100644 index 0dd66c00d..000000000 --- a/docs/dev-notes/2025-10-26/impact-analysis/future_tasks.md +++ /dev/null @@ -1,483 +0,0 @@ -# 後続タスク:ContestTaskPair キー形式変更(Workbooks 対応) - -**作成日**: 2025-10-26 -**対象**: `/workbooks`, `/workbooks/[slug]` ページの互換性対応 -**優先度**: 低(後続フェーズ) - ---- - -## 概要 - -`pre_plan.md` では `/problems` ページの `/problems` ページ対応を優先としていますが、以下の点から workbooks ページの互換性対応も実装が必要になる可能性があります: - -1. **`getTaskResultsByTaskId()` の戻り値型変更** - - 現在: `Map` (キー: `taskId` のみ) - - 新: `Map` (キー: `"contestId:taskId"`) - - workbooks ページがこの関数を呼び出すため、型の一貫性維持が必要 - -2. **`getTaskResultsOnlyResultExists()` の修正** - - `mergeTaskAndAnswer()` への統合(冗長排除) - - `with_map=true` の場合、キー形式を composite key に変更 - -3. **ページ側の修正** - - `src/routes/workbooks/[slug]/+page.server.ts` - - `src/routes/workbooks/[slug]/+page.svelte` - ---- - -## Phase 3-A: `getTaskResultsByTaskId()` と呼び出し元の修正 - -### Step 3-A-1: `getTaskResultsByTaskId()` 修正 - -**ファイル**: `src/lib/services/task_results.ts` (行 190-231) - -**現在のコード**: - -```typescript -export async function getTaskResultsByTaskId( - workBookTasks: WorkBookTasksBase, - userId: string, -): Promise> { - const startTime = Date.now(); - - // ... (処理) - - const taskResultsMap = new Map(); - - for (const taskId of taskIds) { - const task = tasksMap.get(taskId); - if (!task) continue; - - const answer = answersMap.get(taskId); - const taskResult = mergeTaskAndAnswer(task, userId, answer); - - taskResultsMap.set(taskId, taskResult); // ← キー: taskId のみ - } - - return taskResultsMap; -} -``` - -**修正後のコード**: - -```typescript -export async function getTaskResultsByTaskId( - workBookTasks: WorkBookTasksBase, - userId: string, -): Promise { - // ← 戻り値型変更 - const startTime = Date.now(); - - // ... (処理は同じ) - - const taskResultsMap = new Map(); - - for (const taskId of taskIds) { - const task = tasksMap.get(taskId); - if (!task) continue; - - const answer = answersMap.get(taskId); - const taskResult = mergeTaskAndAnswer(task, userId, answer); - - // キー形式を "contestId:taskId" に変更 - const key = createContestTaskPairKey(task.contest_id, taskId); - taskResultsMap.set(key, taskResult); - } - - return taskResultsMap; -} -``` - -**必要なインポート追加**: - -```typescript -import type { - ContestTaskPairKey, - TaskResultMapByContestTaskPair, -} from '$lib/types/contest_task_pair'; -import { createContestTaskPairKey } from '$lib/utils/contest_task_pair'; -``` - -#### チェックリスト - -- [ ] 関数の戻り値型を `TaskResultMapByContestTaskPair` に変更 -- [ ] Map の型定義を `Map` に変更 -- [ ] ループ内で `createContestTaskPairKey()` を使用してキー生成 -- [ ] `task.contest_id` が null でないことを確認 -- [ ] インポート追加 - ---- - -### Step 3-A-2: ページサーバー側の修正 - -**ファイル**: `src/routes/workbooks/[slug]/+page.server.ts` - -#### 修正前 - -```typescript -const taskResults: Map = await taskResultsCrud.getTaskResultsByTaskId( - workBook.workBookTasks, - loggedInUser?.id as string, -); -``` - -#### 修正後 - -```typescript -const taskResults: TaskResultMapByContestTaskPair = await taskResultsCrud.getTaskResultsByTaskId( - workBook.workBookTasks, - loggedInUser?.id as string, -); -``` - -**インポート追加**: - -```typescript -import type { TaskResultMapByContestTaskPair } from '$lib/types/contest_task_pair'; -``` - -#### チェックリスト - -- [ ] 型定義を `TaskResultMapByContestTaskPair` に変更 -- [ ] インポート追加 - ---- - -### Step 3-A-3: ページコンポーネント側の修正 - -**ファイル**: `src/routes/workbooks/[slug]/+page.svelte` - -#### 現在のコード - -```svelte - -``` - -#### 修正後 - -```svelte - -``` - -**⚠️ 注意**: workbook/[slug] で `contestId` を確実に取得できるか詳細な調査が必要 - -#### チェックリスト - -- [ ] 型定義を `TaskResultMapByContestTaskPair` に変更 -- [ ] `.get(taskId)` を `.get(createContestTaskPairKey(...))` に変更 -- [ ] `contestId` の取得元を確定 -- [ ] インポート追加 - ---- - -## Phase 3-B: `getTaskResultsOnlyResultExists()` 修正(オプション) - -### Step 3-B-1: `getTaskResultsOnlyResultExists()` 修正 - -**ファイル**: `src/lib/services/task_results.ts` (行 154-179) - -**現在のコード**: - -```typescript -export async function getTaskResultsOnlyResultExists( - userId: string, - with_map: boolean = false, -): Promise> { - // ... (処理) - - if (with_map) { - return taskResultsMap; // Map で返却 - } else { - return taskResultsWithAnswer; // TaskResults[] で返却 - } -} -``` - -**修正後**: - -```typescript -export async function getTaskResultsOnlyResultExists( - userId: string, - with_map: boolean = false, -): Promise { - const tasks = await getTasks(); - const answers = await answer_crud.getAnswers(userId); - - const tasksHasAnswer = tasks.filter((task) => answers.has(task.task_id)); - - // ⭐ mergeTaskAndAnswer を使用(重複排除) - const taskResults = tasksHasAnswer.map((task: Task) => { - const answer = answers.get(task.task_id); - return mergeTaskAndAnswer(userId, task, answer); - }); - - if (with_map) { - const taskResultsMap = new Map(); - taskResults.forEach((tr) => { - const key = createContestTaskPairKey(tr.contest_id, tr.task_id); - taskResultsMap.set(key, tr); - }); - return taskResultsMap; - } - - return taskResults; -} -``` - -**重要ポイント**: - -1. `mergeTaskAndAnswer()` を使用して重複排除 -2. `with_map=true` の場合、キーを `"contestId:taskId"` に変更 -3. 型を `TaskResultMapByContestTaskPair` に変更 - -#### チェックリスト - -- [ ] `map()` で `mergeTaskAndAnswer()` を使用 -- [ ] `with_map=true` の場合、キーを composite key に -- [ ] 型定義を更新 -- [ ] インポート追加 - ---- - -### Step 3-B-2: 呼び出し元の確認と修正 - -**使用箇所調査**: - -```bash -grep -r "getTaskResultsOnlyResultExists" src/ --include="*.ts" --include="*.svelte" -``` - -見つかった呼び出し元の型を確認し、必要に応じて修正。 - -#### チェックリスト - -- [ ] 呼び出し元を grep で検索 -- [ ] `with_map=true` で呼ばれている箇所を確認 -- [ ] 型を `TaskResultMapByContestTaskPair` に更新 - ---- - -## Phase 3-C: テスト追加 - -### Step 3-C-1: workbooks 関連テスト追加 - -**ファイル**: `src/test/lib/services/task_results.test.ts` (既存ファイルに追加) - -```typescript -describe('getTaskResultsByTaskId', () => { - // ✅ テストケース 1: 戻り値型 - it('should return Map', async () => { - // Arrange - const mockWorkbookTasks = [...]; - const mockUserId = 'test_user_123'; - - // Act - const result = await getTaskResultsByTaskId(mockWorkbookTasks, mockUserId); - - // Assert - expect(result).toBeInstanceOf(Map); - }); - - // ✅ テストケース 2: キー形式 - it('should use "contestId:taskId" format as key', async () => { - const entries = Array.from(result.entries()); - entries.forEach(([key, _]) => { - expect(key).toMatch(/.*:.*$/); // "xxx:yyy" 形式 - }); - }); - - // ✅ テストケース 3: 複数 contestId 対応 - it('should handle multiple contestIds with same taskId', async () => { - const key1 = createContestTaskPairKey('abc101', 'arc099_a'); - const key2 = createContestTaskPairKey('arc099', 'arc099_a'); - - expect(key1).not.toBe(key2); - }); - - // ✅ テストケース 4: 空配列 - it('should return empty map for empty workbook tasks', async () => { - const result = await getTaskResultsByTaskId([], mockUserId); - expect(result.size).toBe(0); - }); -}); - -describe('getTaskResultsOnlyResultExists', () => { - // ✅ テストケース 5: with_map=false - it('should return TaskResults array when with_map=false', async () => { - const result = await getTaskResultsOnlyResultExists('user_123', false); - expect(Array.isArray(result)).toBe(true); - }); - - // ✅ テストケース 6: with_map=true キー形式 - it('should return Map with composite key when with_map=true', async () => { - const result = await getTaskResultsOnlyResultExists('user_123', true); - expect(result).toBeInstanceOf(Map); - - const entries = Array.from(result.entries()); - entries.forEach(([key, _]) => { - expect(key).toMatch(/.*:.*$/); - }); - }); - - // ✅ テストケース 7: mergeTaskAndAnswer 統合確認 - it('should use mergeTaskAndAnswer for all results', async () => { - const result = await getTaskResultsOnlyResultExists('user_123', false); - expect(result.length).toBeGreaterThan(0); - - result.forEach((tr) => { - // TaskResult が正しくマージされていることを確認 - expect(tr.contest_id).toBeDefined(); - expect(tr.task_id).toBeDefined(); - }); - }); -}); -``` - -#### チェックリスト - -- [ ] テストケース追加 -- [ ] テスト実行確認 - ---- - -## Phase 3-D: 目視テスト - -### Step 3-D-1: `/workbooks` ページ確認 - -**URL**: `http://localhost:5174/workbooks` - -#### 確認項目 - -| # | 項目 | 期待値 | 確認方法 | -| --- | ---------------- | ---------- | -------------- | -| 1 | ページ表示 | 正常に表示 | ブラウザで確認 | -| 2 | workbook リスト | 複数行表示 | UI 確認 | -| 3 | コンソールエラー | エラーなし | ブラウザ F12 | - -#### チェックリスト - -- [ ] ページ表示正常 -- [ ] workbook リスト表示正常 -- [ ] コンソールエラーなし - ---- - -### Step 3-D-2: `/workbooks/[slug]` ページ確認 - -**URL**: `http://localhost:5174/workbooks/stack` (例) - -#### 確認項目 - -| # | 項目 | 期待値 | 確認方法 | -| --- | ---------------- | ----------------------------------- | ----------------------- | -| 1 | ページ表示 | 正常に表示 | ブラウザで確認 | -| 2 | タスク一覧 | 複数タスク表示 | UI 確認 | -| 3 | タスク状態更新 | 更新が反映 | UI 操作 | -| 4 | コンソールエラー | エラーなし | ブラウザ F12 | -| 5 | 複数 contestId | 同一 taskId の複数 contestId を表示 | UI 確認(必要に応じて) | - -#### チェックリスト - -- [ ] ページ表示正常 -- [ ] タスク一覧表示正常 -- [ ] 状態更新機能正常 -- [ ] コンソールエラーなし - ---- - -## 実装チェックリスト - -### ✅ Phase 3-A 完了(必須) - -- [ ] `getTaskResultsByTaskId()` 戻り値型変更 -- [ ] ページサーバー型更新 -- [ ] ページコンポーネント型更新 -- [ ] `contestId` 取得元確定 -- [ ] インポート追加 - -### ✅ Phase 3-B 完了(オプション) - -- [ ] `getTaskResultsOnlyResultExists()` 修正(判断後) -- [ ] 呼び出し元の型更新 - -### ✅ Phase 3-C 完了 - -- [ ] テスト追加 -- [ ] テスト全成功 - -### ✅ Phase 3-D 完了 - -- [ ] `/workbooks` ページ確認 -- [ ] `/workbooks/[slug]` ページ確認 -- [ ] コンソールエラーなし - ---- - -## 予想される実装工数 - -| フェーズ | 項目 | 工数 | -| ------------------ | ------------------------------------------------- | ---------------------------- | -| Phase 3-A-1 | `getTaskResultsByTaskId()` 修正 | 20 分 | -| Phase 3-A-2 | ページサーバー型更新 | 5 分 | -| Phase 3-A-3 | ページコンポーネント修正 | 15 分 | -| **Phase 3-A 小計** | | **40 分** | -| Phase 3-B-1 | `getTaskResultsOnlyResultExists()` 修正(判断後) | 15 分 | -| Phase 3-B-2 | 呼び出し元修正 | 10 分 | -| **Phase 3-B 小計** | | **25 分**(オプション) | -| Phase 3-C | テスト追加 | 15 分 | -| Phase 3-D | 目視テスト | 20 分 | -| **合計** | | **1.5 時間(Phase 3-A~D)** | -| **合計(B 含む)** | | **2 時間** | - ---- - -## 実装上の注意 - -### ⚠️ Workbook での contestId の取得 - -**問題**: workbook に複数の contestId が含まれる可能性がある - -**調査項目**: - -1. WorkBook テーブルに `contest_id` カラムがあるか -2. WorkBookTask テーブルに `contest_id` カラムがあるか -3. `/workbooks/[slug]/+page.svelte` でタスクを表示する際、どこから `contestId` を取得するか - -**判定方法**: DB スキーマと実装を確認 - ---- - -## 関連ドキュメント - -- `pre_plan.md`: `/problems` ページの実装計画(優先) -- `report.md`: 包括的な影響範囲分析 -- `phase1-findings.md`: 初期調査結果 -- `phase2-analysis.md`: 深い技術分析 - ---- - -**次ステップ**: `pre_plan.md` の実装完了後、このドキュメントに基づいて Phase 3-A~D を段階的に実装。 diff --git a/docs/dev-notes/2025-10-26/impact-analysis/phase1-findings.md b/docs/dev-notes/2025-10-26/impact-analysis/phase1-findings.md deleted file mode 100644 index 8a176aefc..000000000 --- a/docs/dev-notes/2025-10-26/impact-analysis/phase1-findings.md +++ /dev/null @@ -1,207 +0,0 @@ -# Phase 1: 直接的な依存関係の調査結果 - -調査日: 2025-10-26 - -対象: `getTaskResults()` と `getTasksWithTagIds()` の呼び出し元と `Map` の利用箇所 - ---- - -## 1. 起点となる関数の呼び出し元 - -### 1.1 getTaskResults() - 呼び出し元 - -| ファイル | 行番号 | コンテキスト | -| --------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | -| `src/routes/problems/+page.server.ts` | 26 | `taskResults: (await crud.getTaskResults(session?.user.userId)) as TaskResults,` | -| `src/routes/users/[username]/+page.server.ts` | 33 | `const taskResultsMap = await taskResultService.getTaskResultsOnlyResultExists(...)` | -| `src/routes/workbooks/+page.server.ts` | 39 | `const taskResultsByTaskId = await taskResultsCrud.getTaskResultsOnlyResultExists(...)` | - -### 1.2 getTasksWithTagIds() - 呼び出し元 - -| ファイル | 行番号 | コンテキスト | -| ------------------------------------- | ------ | -------------------------------------------------------------------------------------------- | -| `src/routes/problems/+page.server.ts` | 20 | `taskResults: (await crud.getTasksWithTagIds(tagIds, session?.user.userId)) as TaskResults,` | - -**修正が必要な理由**: - -- `relateTasksAndAnswers()` を使用(削除対象) -- ContestTaskPair に非対応(タグフィルタ後のタスクで同一 taskId 複数 contestId に対応していない) -- `/problems?tags=...` ページで複数コンテストの同じ問題が表示されない - -**修正方法**: - -- `getMergedTasksMap(filteredTasks)` に タグフィルタ後のタスク配列を渡す -- ヘルパー関数 `createTaskResults()` で統一マージ -- 詳細は [`report.md` Section 3.2](report.md#32-サービス層gettagswithtaskids新規追加対象) を参照 - ---- - -## 1.3 その他の関連関数 - -| 関数名 | ファイル | 状態 | 備考 | -| --------------------------- | ---------------------------------- | -------- | --------------------------------------------------------------------- | -| `createTaskResults()` | `src/lib/services/task_results.ts` | 新規追加 | ヘルパー関数。`getTaskResults()` と `getTasksWithTagIds()` で共通使用 | -| `getMergedTasksMap(tasks?)` | `src/lib/services/tasks.ts` | 拡張必要 | `tasks` パラメータを optional に拡張 | - ---- - -## 2. Map 利用箇所一覧 - -### 2.1 型定義 - -| ファイル | 行番号 | 用途 | -| --------------------------------------------------- | ---------- | ------------------------------------------------------------------------- | -| `src/lib/types/contest_task_pair.ts` | 25 | `TaskResultMapByContestTaskPairKey = Map` | -| `src/test/lib/utils/test_cases/account_transfer.ts` | 16, 96, 98 | テスト用サンプルデータ | -| `src/lib/utils/account_transfer.ts` | 16, 69 | アカウント転送用ユーティリティの型定義 | - -### 2.2 サービス層での生成・操作 - -| ファイル | 行番号 | 関数名 | 説明 | -| ---------------------------------- | ------- | ---------------------------------- | ------------------------------------------------------------------- | -| `src/lib/services/tasks.ts` | 41-65 | `getMergedTasksMap(tasks?)` | ⭐ **新規拡張**: ContestTaskPair マップ。tasks パラメータで DI 対応 | -| `src/lib/services/task_results.ts` | - | `createTaskResults()` | ⭐ **新規追加**: Task 配列を answers とマージ。統一ポイント | -| `src/lib/services/task_results.ts` | 88, 96 | `transferAnswers()` | ソースユーザ・宛先ユーザの回答を Map として取得 | -| `src/lib/services/task_results.ts` | 154-179 | `getTaskResultsOnlyResultExists()` | 回答ありのみ返すメソッド(Map 可能) | -| `src/lib/services/task_results.ts` | 190-231 | `getTaskResultsByTaskId()` | 指定タスク ID のみ返すメソッド(workbooks 用) | - -### 2.3 コンポーネント層での利用 - -| ファイル | 行番号 | 用途 | キー参照方法 | -| ------------------------------------------------------- | ------- | ------------------------------- | --------------------------------- | -| `src/lib/components/TaskTables/TaskTable.svelte` | 126-132 | TaskResults 配列から Map へ変換 | `taskResult.task_id` をキー | -| `src/lib/components/TaskTables/TaskTable.svelte` | 146-152 | Map での検索・インデックス操作 | `.get(taskId)` 直接参照 | -| `src/lib/components/TaskGradeList.svelte` | 18-55 | 成績別に Map 作成 | `.get(taskGrade)` で グループ分け | -| `src/lib/components/WorkBooks/WorkBookBaseTable.svelte` | 33 | props 型として使用 | - | -| `src/lib/components/WorkBooks/WorkBookList.svelte` | 23-88 | workbook 別の TaskResults 集約 | `.get(workbookType)` でグループ化 | -| `src/lib/components/WorkBook/WorkBookForm.svelte` | 46 | タスク情報参照 | `.get(taskId)` 直接参照 | - -### 2.4 ページ層での利用 - -| ファイル | 行番号 | 用途 | -| ----------------------------------------------------- | --------- | ----------------------------------------------- | -| `src/routes/problems/+page.server.ts` | 4, 20, 26 | TaskResults を props として渡す(形式は Array) | -| `src/routes/problems/[slug]/+page.server.ts` | 16, 31 | 単一タスク結果の取得・更新 | -| `src/routes/workbooks/+page.svelte` | 70 | Map 形式で受け取り | -| `src/routes/workbooks/[slug]/+page.server.ts` | 27 | Map 形式で返却 | -| `src/routes/workbooks/[slug]/+page.svelte` | 44 | `.get(taskId)` で参照 | -| `src/routes/(admin)/account_transfer/+page.server.ts` | 62 | `copyTaskResults()` でユーザ間転送 | - ---- - -## 3. taskId をキーとした直接 Map アクセス - -### 3.1 アクセスパターン一覧 - -| ファイル | 行番号 | パターン | 説明 | -| ------------------------------------------------------------ | ------ | ------------------------------------------- | ---------------------------- | -| `src/lib/services/task_results.ts` | 213 | `tasksMap.get(taskId)` | Task マップからタスク取得 | -| `src/lib/services/task_results.ts` | 220 | `answersMap.get(taskId)` | 回答マップから回答取得 | -| `src/lib/services/task_results.ts` | 223 | `taskResultsMap.set(taskId, taskResult)` | 結果マップに結果を登録 | -| `src/lib/components/WorkBookTasks/WorkBookTasksTable.svelte` | 133 | `tasksMapByIds.get(taskId)` | コンポーネント内でタスク参照 | -| `src/lib/components/TaskTables/TaskTable.svelte` | 152 | `taskIndicesMap().get(updatedTask.task_id)` | インデックスマップから参照 | -| `src/routes/workbooks/[slug]/+page.svelte` | 44 | `taskResults?.get(taskId)` | ページ内で結果参照 | - ---- - -## 4. task_results サービス層の全体像 - -### 4.1 他からインポートされる関数一覧 - -```text -src/routes/problems/+page.server.ts - ├─ getTaskResults() - ├─ getTasksWithTagIds() - └─ updateTaskResult() - -src/routes/problems/[slug]/+page.server.ts - ├─ getTaskResult() - └─ updateTaskResult() - -src/routes/users/[username]/+page.server.ts - └─ getTaskResultsOnlyResultExists() - -src/routes/workbooks/+page.server.ts - └─ getTaskResultsOnlyResultExists() - -src/routes/workbooks/[slug]/+page.server.ts - └─ getTaskResultsByTaskId() - -src/routes/(admin)/account_transfer/+page.server.ts - └─ copyTaskResults() -``` - -### 4.2 内部関数一覧 - -- `relateTasksAndAnswers()` - TaskResults 生成の中核 -- `createDefaultTaskResult()` - デフォルト TaskResult 作成 -- `transferAnswers()` - アカウント転送時の回答転送 -- `mergeTaskAndAnswer()` - Task と Answer を統合 - ---- - -## 5. リスク区分 - -### 高リスク(必須修正) - -- **`src/lib/services/task_results.ts`** - - `getTaskResultsByTaskId()` - 直接 `taskId` をキーに利用(213, 220, 223 行) - - `getTaskResultsOnlyResultExists()` - `taskId` を直接キーに登録(173 行) - - `relateTasksAndAnswers()` - 配列返却用だが内部では taskId を利用 - -- **`src/routes/problems/+page.server.ts`** - - `getTaskResults()` と `getTasksWithTagIds()` の戻り値型が `TaskResults` (Array) - - しかし新構造では `Map` が必要 - -### 中リスク(間接的な影響) - -- **`src/lib/components/TaskTables/TaskTable.svelte`** - - TaskResults 配列を Map に変換(127行) - - キーが taskId なので、新構造対応が必要 - -- **`src/routes/workbooks/[slug]/+page.svelte`** - - 既に Map 型で受け取り `.get(taskId)` で参照(44行) - - キー構造の変更が必要 - -### 低リスク(読み取りのみ) - -- `src/routes/problems/[slug]/+page.server.ts` - - 単一タスク参照なので、現在の構造でも対応可能かもしれない - ---- - -## 6. 推奨される修正優先順序 - -1. **Phase 2**: サービス層(`src/lib/services/task_results.ts`)を修正 - - `getTaskResultsByTaskId()` - キー形式を変更 - - `getTaskResultsOnlyResultExists()` - キー形式を変更 - - ヘルパー関数を新構造対応に改修 - -2. **Phase 3**: ページサーバー層(`src/routes/*/+page.server.ts`)を修正 - - `/problems` ページ:戻り値型の見直し - - `/workbooks` ページ:キー形式の一貫性確認 - -3. **Phase 4**: コンポーネント層(`src/lib/components/*`)を修正 - - TaskTable での Map 変換処理 - - workbooks での参照処理 - -4. **Phase 5**: ユーティリティ層(`src/lib/utils/account_transfer.ts`)を修正 - - アカウント転送の型・ロジック調整 - ---- - -## 7. 既知の新しい型定義 - -| 型名 | ファイル | 説明 | -| ----------------------------------- | ------------------------------------ | ----------------------------- | -| `TaskResultMapByContestTaskPairKey` | `src/lib/types/contest_task_pair.ts` | 新しいキー形式用の Map 型 | -| `ContestTaskPairKey` | `src/lib/types/contest_task_pair.ts` | `contestId:taskId` 形式のキー | - ---- - -## 8. 次のステップ(Phase 2 候補) - -- [ ] `src/lib/services/tasks.ts` の `getMergedTasksMap()` 実装内容確認 -- [ ] ContestTaskPair テーブルの実装内容確認 -- [ ] `/problems` ページの現在の TaskResults 返却形式を詳細確認 -- [ ] `/workbooks` との互換性要件の最終確認 diff --git a/docs/dev-notes/2025-10-26/impact-analysis/phase2-analysis.md b/docs/dev-notes/2025-10-26/impact-analysis/phase2-analysis.md deleted file mode 100644 index 445998744..000000000 --- a/docs/dev-notes/2025-10-26/impact-analysis/phase2-analysis.md +++ /dev/null @@ -1,458 +0,0 @@ -# Phase 2: 新キー構造の実装詳細と影響分析 - -調査日: 2025-10-26 - -対象: 新しいキー構造 (`contestId:taskId`) の導入による詳細な技術分析 - ---- - -## 1. 既存の新規型定義とユーティリティ - -### 1.1 型定義(既に実装済み) - -**ファイル**: `src/lib/types/contest_task_pair.ts` - -```typescript -export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" -export type TaskMapByContestTaskPair = Map; -export type TaskResultMapByContestTaskPair = Map; -``` - -### 1.2 ユーティリティ関数(既に実装済み) - -**ファイル**: `src/lib/utils/contest_task_pair.ts` - -```typescript -export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey; -``` - -- エラーハンドリング付きで `"contestId:taskId"` 形式のキーを生成 - -### 1.3 既存のマージ機能 - -**ファイル**: `src/lib/services/tasks.ts` - -```typescript -export async function getMergedTasksMap(): Promise; -``` - -- ContestTaskPair テーブルのデータを使って、複数 contestId に対応する Task を Map として返却 -- キー形式: `ContestTaskPairKey` (`"contestId:taskId"`) -- 時間計算量: O(N + M) - ---- - -## 2. TaskResult オブジェクトの構造分析 - -### 2.1 型定義 - -**ファイル**: `src/lib/types/task.ts` - -```typescript -export interface Task { - contest_type?: ContestType; - contest_id: string; // ✅ 既に含まれている - task_table_index: string; - task_id: string; - title: string; - grade: string; -} - -export interface TaskResult extends Task { - user_id: string; - status_name: string; - status_id: string; - submission_status_image_path: string; - submission_status_label_name: string; - is_ac: boolean; - updated_at: Date; -} - -export type TaskResults = TaskResult[]; -``` - -### 2.2 重要な発見 - -✅ **TaskResult は既に `contest_id` を含んでいる** - -- 子孫コンポーネントでも `taskResult.contest_id` と `taskResult.task_id` で一意に識別可能 -- 例: `src/lib/components/TaskList.svelte` (行92): `id={taskResult.contest_id + '-' + taskResult.task_id}` - ---- - -## 3. Map 変換の詳細調査 - -### 3.1 Map 変換が発生している箇所 - -| ファイル | 行番号 | 処理内容 | 変換回数 | -| ----------------------------------------- | ------- | ------------------------------------------------ | -------- | -| `src/lib/components/TaskTable.svelte` | 126-132 | `TaskResults[]` → `Map` | 1回 | -| `src/lib/components/TaskGradeList.svelte` | 18-33 | `TaskResults[]` → `Map` | 1回 | - -### 3.2 TaskTable.svelte での Map 変換詳細 - -**コード**: - -```typescript -let taskResultsMap = $derived(() => { - return taskResults.reduce((map: Map, taskResult: TaskResult) => { - if (!map.has(taskResult.task_id)) { - map.set(taskResult.task_id, taskResult); // キー: taskId のみ - } - return map; - }, new Map()); -}); -``` - -**問題点**: - -- キーが `taskId` のみで、同一 taskId 異なる contestId の場合に衝突する -- 行 152 で `taskIndicesMap().get(updatedTask.task_id)` で参照 -- **複数回の変換はない**(1回のみ) - -### 3.3 TaskGradeList.svelte での Map 変換 - -**コード**: - -```typescript -let taskResultsForEachGrade = $state(new Map()); - -run(() => { - taskResultsForEachGrade = new Map(); - taskGradeValues.map((grade) => { - taskResultsForEachGrade.set( - grade, - taskResults.filter((taskResult: TaskResult) => taskResult.grade === grade), - ); - }); -}); -``` - -**特徴**: - -- グレードごとに配列を集約(Map ではなく配列を保持) -- Map 変換は1回のみ - ---- - -## 4. サービス層(task_results.ts)の Map 操作分析 - -### 4.1 getTaskResultsByTaskId() 関数 - -**実装**: - -```typescript -export async function getTaskResultsByTaskId( - workBookTasks: WorkBookTasksBase, - userId: string, -): Promise>; -``` - -**処理フロー**: - -1. taskIds 抽出 -2. バルクフェッチ(Task, Answer) -3. 内部 Map 作成(キー: `taskId`) -4. Map で返却 - -**問題点**: - -- キーが `taskId` のみ -- ContestTaskPair 対応なし - -### 4.2 getTaskResultsOnlyResultExists() 関数 - -**実装**: - -```typescript -export async function getTaskResultsOnlyResultExists( - userId: string, - with_map: boolean = false, -): Promise>; -``` - -**特徴**: - -- 配列またはMap を返却可能 -- キーが `taskId` のみ -- with_map 引数で制御 - -### 4.3 getTaskResults() と getTasksWithTagIds() 関数 - -| 関数名 | 戻り値型 | キー形式 | 備考 | -| ---------------------- | --------------- | -------- | ---------------------------------------------------------------------------------- | -| `getTaskResults()` | `TaskResults[]` | N/A | 配列で返却、**内部的に `getMergedTasksMap()` を使用** | -| `getTasksWithTagIds()` | `TaskResults[]` | N/A | 配列で返却、**タグフィルタ後のタスクを `getMergedTasksMap(filteredTasks)` に渡す** | - -**用途**: `/problems` ページ(配列のまま受け取り) - -**getTaskResults() 実装の重要変更**: - -- 旧: `getTasks()` + answers マップをマージ -- 新: `getMergedTasksMap()` で `"contestId:taskId"` キー形式の Task を取得 → answers とマージ -- 効果: ContestTaskPair に対応した Task を `/problems` ページで使用可能に - -**getTasksWithTagIds() 実装の重要変更**: - -- 旧: DB から直接タグフィルタしたタスク → `relateTasksAndAnswers()` でマージ -- 新: DB からタグフィルタしたタスク → **`getMergedTasksMap(filteredTasks)` に渡す** → `createTaskResults()` でマージ -- 効果: - 1. タグフィルタ後の **ContestTaskPair マージ** に対応 - 2. 同一 taskId で複数 contestId の場合、全ての (contestId, taskId) ペアが表示される - 3. `/problems?tags=...` で複数コンテストの同じ問題がタグフィルタで表示可能 - -**`getMergedTasksMap(tasks?)` の拡張(DI 的設計)**: - -```typescript -export async function getMergedTasksMap(tasks?: Task[]): Promise { - const tasksToMerge = tasks ?? (await getTasks()); - // 既存のマージロジック - return mergeWithContestTaskPairs(tasksToMerge); -} -``` - -- ✅ `tasks` 未指定: 通常ケース(全タスク取得) -- ✅ `tasks` 指定: タグフィルタ後など、特定タスクセットのマージ -- ✅ テスト容易性向上(Mock Task 配列を注入可能) - ---- - -## 5. コンポーネント層での contest_id 利用パターン - -### 5.1 contest_id を利用しているコンポーネント - -| ファイル | 用途 | キー | 備考 | -| ---------------------------------- | ---------- | --------------------------------------- | ---------------- | -| `TaskList.svelte:92` | ID属性 | `contest_id + '-' + task_id` | 既に複合キー使用 | -| `TaskList.svelte:105, 117` | URL/表示 | contest_id と task_id を別々に使用 | - | -| `TaskTableBodyCell.svelte:49` | URL | `getTaskUrl(contest_id, task_id)` | - | -| `TaskSearchBox.svelte:48, 211-212` | 検索・表示 | `getTaskUrl(contest_id, task_id)` | - | -| `TaskGradeList.svelte` | フィルタ | grade でグループ化(contest_id 未使用) | - | - -### 5.2 重要な発見 - -✅ コンポーネント層は既に `contest_id` と `task_id` を併用している - -- ID 属性や URL 生成で複合キーを使用 -- キー統一による影響は **コンポーネント側では最小限** - ---- - -## 6. 新キー構造の導入戦略(ユーザー回答に基づく) - -### 6.1 方針確認(ユーザー回答より) - -**Q1**: TaskResult で contestId:taskId が一意に識別できるのなら、そのままが望ましい - -- **判定**: ✅ TaskResult は `contest_id` を含むので識別可能 -- **結論**: **コンポーネント層の大きな変更は不要** - -**Q2**: workbook 内は taskId のみで十分 - -- **判定**: ✅ 確認(workbook 内は同一 taskId は複数 contestId を持たない) -- **結論**: `/workbooks` は **互換性維持(現状のまま)** ← 将来拡張可能性あり - -**Q3**: オプションB選択(新型 `TaskResultMapByContestTaskPair` を使用) - -- **対象**: `getTaskResultsByTaskId()` など Map 返却関数 - -**Q4**: 配列のまま、複数回変換なら server 時点で Map - -- **判定**: ✅ 複数回変換なし(TaskTable で1回のみ) -- **結論**: **配列のまま配信し、コンポーネント側で1回変換** で OK - ---- - -## 7. 詳細な修正対象箇所 - -### 7.1 必須修正(新キー構造対応) - -#### ✅ サービス層 - -| ファイル | 関数 | 修正内容 | 優先度 | -| ---------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------- | ------ | -| `src/lib/services/task_results.ts` | `getTaskResultsByTaskId()` | キーを `"contestId:taskId"` に変更、戻り値型を `TaskResultMapByContestTaskPair` に | 🔴 高 | -| `src/lib/services/task_results.ts` | `mergeTaskAndAnswer()` | task から contest_id を確実に取得 | 🔴 高 | -| `src/lib/services/task_results.ts` | `getTaskResultsOnlyResultExists()` | with_map=true の場合、キーを `"contestId:taskId"` に | 🟡 中 | - -#### ❌ ページサーバー層(変更不要) - -| ファイル | 理由 | -| -------------------------------------------- | --------------------------------------------------------- | -| `src/routes/problems/+page.server.ts` | `TaskResults[]` 配列のままで OK(コンポーネント側で変換) | -| `src/routes/problems/[slug]/+page.server.ts` | 単一タスク参照なので変更不要 | - -#### 🟡 コンポーネント層(最小限の変更) - -| ファイル | 変更内容 | 理由 | -| --------------------------------------------- | ---------------------------------------- | -------------------------------------------------- | -| `src/lib/components/TaskTable.svelte:126-132` | Map のキーを `"contestId:taskId"` に変更 | taskId のみキーだと同一タスク複数 contestId で衝突 | -| `src/lib/components/TaskGradeList.svelte` | 変更不要 | グレード別フィルタは contest_id 無関係 | - -#### 🟡 workbook 層(互換性維持、将来対応予定) - -| ファイル | 現状 | 将来対応 | -| --------------------------------------------- | ---------------------------------------- | --------------------------------------------- | -| `src/routes/workbooks/[slug]/+page.server.ts` | `Map` (キー: taskId) | 可能性: `Map<"contestId:taskId", TaskResult>` | -| `src/routes/workbooks/[slug]/+page.svelte` | `.get(taskId)` で参照 | 要見直し | - ---- - -## 8. /workbooks ページの詳細分析 - -### 8.1 現状 - -**ファイル**: `src/routes/workbooks/[slug]/+page.server.ts` (行27) - -```typescript -const taskResults: Map = await taskResultsCrud.getTaskResultsByTaskId( - workBook.workBookTasks, - loggedInUser?.id as string, -); -``` - -**ファイル**: `src/routes/workbooks/[slug]/+page.svelte` (行44) - -```typescript -return taskResults?.get(taskId) as TaskResult; -``` - -### 8.2 互換性維持の理由 - -- Workbook は特定の問題集に紐付いており、同じ taskId は複数の contestId を持たない -- 現在のキー形式(taskId のみ)で十分機能 -- 将来的に「同一問題を複数コンテストバージョンで提供」するまでは対応不要 - -### 8.3 将来対応時の計画(メモ) - -``` -Workbook の拡張が必要になる場合: -1. WorkbookTask に contest_id を追加する検討 -2. キー形式を "contestId:taskId" に移行 -3. getTaskResultsByTaskId() の呼び出し箇所を確認 -4. ページコンポーネント側の .get(taskId) を .get(createContestTaskPairKey(...)) に変更 -``` - ---- - -## 9. アカウント転送機能への影響 - -**ファイル**: `src/lib/services/task_results.ts` (行88, 96) - -```typescript -const sourceUserAnswers: Map = await answer_crud.getAnswers(sourceUser.id); -const destinationUserAnswers: Map = await answer_crud.getAnswers( - destinationUser.id, -); -``` - -### 分析 - -- `answer_crud.getAnswers()` は `Map` を返却(キー: taskId) -- **現在は変更不要**(答えの転送は taskId ベースで十分) -- 将来的に ContestTaskPair 対応時に見直し検討 - ---- - -## 10. 修正対象の一覧(まとめ) - -### 🔴 必須修正(新キー対応) - -1. **`src/lib/services/task_results.ts`** - - `getTaskResults()`: getMergedTasksMap() と mergeTaskAndAnswer() を利用 - - `mergeTaskAndAnswer()`: contest_id 取得確認 - -2. **`src/lib/components/TaskTable.svelte`** - - `taskResultsMap` の Map キーを `"contestId:taskId"` に変更 - - `taskIndicesMap` も同様に変更(参照箇所: 行152) - -### 🟡 オプション(互換性を考慮) - -3. **`src/lib/services/task_results.ts`** - - `getTaskResultsByTaskId()`: キー形式 + 戻り値型変更 - - `getTaskResultsOnlyResultExists()`: with_map=true 時のキー形式 - -4. **`src/routes/workbooks/[slug]/+page.svelte`** - - 現在は互換性維持(変更不要) - - 将来の workbook 拡張時に対応 - -5. **`src/lib/utils/account_transfer.ts`** - - 現在は taskId ベースで問題なし - - 将来検討 - ---- - -## 11. リスク評価 - -### 11.1 リスク高:直接的なキー依存 - -- **TaskTable.svelte の taskResultsMap** - - 理由: 同一 taskId の複数 contestId 対応時に衝突発生の可能性 - - 影響: 表示ロジック不正 - -### 11.2 リスク中:サービス層の Map 生成 - -- **getTaskResultsByTaskId()** - - 理由: キー形式の変更は広範囲に影響 - - 影響: workbooks ページの動作確認が必要 - -### 11.3 リスク低:タイプセーフティ - -- **型定義の追加** - - `TaskResultMapByContestTaskPair` 使用により防止可能 - ---- - -## 12. テスト戦略 - -### 12.1 単体テスト対象 - -- `createContestTaskPairKey()` - 既存テスト確認 -- `getMergedTasksMap()` - 既存テスト確認 -- `getTaskResultsByTaskId()` - 新キー形式でのテスト必要 - -### 12.2 統合テスト対象 - -- `/problems` ページでの複数コンテスト表示 -- `/workbooks` ページでの互換性確認 -- アカウント転送機能での動作確認 - -### 12.3 既知の制約 - -- utils 以外はテストがほぼない -- 目視確認が必要な部分が多い - ---- - -## 13. 次のステップ(Phase 3 候補) - -- [ ] `src/lib/services/task_results.ts` の `getTaskResultsByTaskId()` 修正実装 -- [ ] `src/lib/components/TaskTable.svelte` の Map キー変更実装 -- [ ] `/problems` ページでの複数 contestId:taskId ペアの動作確認 -- [ ] `/workbooks` ページの互換性確認 -- [ ] 目視テスト実施 - ---- - -## 14. 参考情報 - -### 既存の複合キー使用例 - -**TaskList.svelte (行92)**: - -```svelte -id={taskResult.contest_id + '-' + taskResult.task_id} -``` - -→ コンポーネント層は既に複合キーの概念を認識している - -### 既存の新型定義 - -**contest_task_pair.ts**: - -```typescript -export type ContestTaskPairKey = `${string}:${string}`; -export type TaskResultMapByContestTaskPair = Map; -``` - -→ 型システムとしての準備は完了している diff --git a/docs/dev-notes/2025-10-26/impact-analysis/plan.md b/docs/dev-notes/2025-10-26/impact-analysis/plan.md deleted file mode 100644 index f9c528ee3..000000000 --- a/docs/dev-notes/2025-10-26/impact-analysis/plan.md +++ /dev/null @@ -1,1100 +0,0 @@ -# 実装前計画:ContestTaskPair キー形式変更 - -**作成日**: 2025-10-26 - -**対象ブランチ**: #2750 - -**プリシージャ**: 段階的実装と検証 - ---- - -## 実装の全体像 - -``` -┌─────────────────────────────────────────────────────────┐ -│ ContestTaskPair キー形式変更プロジェクト │ -│ "taskId" → "contestId:taskId" │ -└─────────────────────────────────────────────────────────┘ - │ - ┌─────────────────┼─────────────────┐ - │ │ │ - Phase 1-A Phase 1-B Phase 2 - サービス層 コンポーネント層 検証 - └─────────┘ └──────────────┘ └──────┘ -``` - ---- - -## Phase 1-A: サービス層修正(最優先・複合修正) - -### Step 1-A-0: 関数の重複排除と統合 - -**背景**: `relateTasksAndAnswers()`, `getTaskResultsOnlyResultExists()`, `mergeTaskAndAnswer()` の3関数が類似した処理を含んでいます。共通処理を `mergeTaskAndAnswer()` に一本化します。 - -#### 重複排除の全体像 - -```typescript -// 3つの関数の処理フロー統一 - -// Before: 重複処理 -relateTasksAndAnswers() - ├─ tasks.map() → createDefaultTaskResult() + answer マージ - └─ getTaskResultsOnlyResultExists() - ├─ filter() → createDefaultTaskResult() + answer マージ - -// After: 統一ポイント -mergeTaskAndAnswer() ← 統一ポイント - ├─ getTaskResults() で使用 - ├─ getTaskResultsOnlyResultExists() で使用 - └─ getTaskResultsByTaskId() で使用 -``` - ---- - -### Step 1-A-1: `getTaskResults()` 修正(メイン関数) - -**ファイル**: `src/lib/services/task_results.ts` (行 29-37) - -**この修正が最優先の理由**: - -- `/problems` ページのメイン取得関数 -- `getTasks()` では ContestTaskPair に非対応 -- `relateTasksAndAnswers()` の削除対象関数を使用中 - -#### 修正前のコード - -```typescript -export async function getTaskResults(userId: string): Promise { - // 問題と特定のユーザの回答状況を使ってデータを結合 - // 計算量: 問題数をN、特定のユーザの解答数をMとすると、O(N + M)になるはず。 - const tasks = await getTasks(); // ← 古い:ContestTaskPair 未対応 - const answers = await answer_crud.getAnswers(userId); - - return await relateTasksAndAnswers(userId, tasks, answers); // ← 削除対象 -} -``` - -#### 修正後のコード - -#### 修正後のコード(改善版:`getMergedTasksMap(tasks?)` パターン) - -**まず `getMergedTasksMap()` を拡張**: - -```typescript -// src/lib/services/tasks.ts -export async function getMergedTasksMap(tasks?: Tasks): Promise { - // tasks が渡された場合 → そのまま使用(タグフィルタ後のタスク など) - // tasks が渡されない場合 → DB から取得(通常のケース) - const tasksToMerge = tasks ?? (await getTasks()); - const contestTaskPairs = await getContestTaskPairs(); - - const baseTaskMap = new Map( - tasksToMerge.map((task) => [createContestTaskPairKey(task.contest_id, task.task_id), task]), - ); - - // ContestTaskPair の処理(既存ロジック) - // ... - - return new Map([...baseTaskMap, ...additionalTaskMap]); -} -``` - -**ヘルパー関数を作成**: - -```typescript -// src/lib/services/task_results.ts -async function createTaskResults(tasks: Tasks, userId: string): Promise { - const answers = await answerCrud.getAnswers(userId); - const isLoggedIn = userId !== undefined; - - return tasks.map((task: Task) => { - const answer = isLoggedIn ? answers.get(task.task_id) : null; // Only use taskId - return mergeTaskAndAnswer(task, userId, answer); - }); -} -``` - -**`getTaskResults()` の修正**: - -```typescript -export async function getTaskResults(userId: string): Promise { - // Step 1: getMergedTasksMap で ContestTaskPair に対応(tasks なし = DB から全取得) - const mergedTasksMap = await getMergedTasksMap(); - const tasks = [...mergedTasksMap.values()]; - - // Step 2: createTaskResults で answer と merge - return await createTaskResults(tasks, userId); -} -``` - -**`getTasksWithTagIds()` の修正**: - -```typescript -export async function getTasksWithTagIds( - tagIds_string: string, - userId: string, -): Promise { - const tagIds = tagIds_string.split(','); - - // Step 1: タグから task_id を抽出(既存ロジック) - const taskIdByTagIds = await db.taskTag.groupBy({ - by: ['task_id'], - where: { tag_id: { in: tagIds } }, - having: { task_id: { _count: { equals: tagIds.length } } }, - }); - - const taskIds = taskIdByTagIds.map((item) => item.task_id); - - if (taskIds.length === 0) { - return []; - } - - // Step 2: 該当する task_id のみ DB から取得 - const filteredTasks = await db.task.findMany({ - where: { task_id: { in: taskIds } }, - }); - - // Step 3: getMergedTasksMap(tasks?) に渡す(タグフィルタ済みタスク) - const mergedTasksMap = await getMergedTasksMap(filteredTasks); - const tasks = [...mergedTasksMap.values()]; - - // Step 4: createTaskResults で answer と merge - return await createTaskResults(tasks, userId); -} -``` - -#### 修正のポイント - -1. **`getMergedTasksMap(tasks?)` - オプショナル拡張** - - `tasks` なし: DB から全 Task 取得 → ContestTaskPair merge - - `tasks` あり: 渡されたタスクのみ → ContestTaskPair merge - - **責任が一箇所に集約** → テスト容易、保守性向上 - -2. **ヘルパー関数 `createTaskResults()`** - - Task 配列と answers をマージ - - `mergeTaskAndAnswer()` を使用 - - 重複コード排除 - -3. **`getTasksWithTagIds()` の修正** - - タグフィルタ後のタスクを `getMergedTasksMap(filteredTasks)` に渡す - - ContestTaskPair に対応 - - `/problems?tags=...` ページで複数 contestId の同一タスクが表示される - -4. **`answers` のキーは `taskId` のみ** - - TaskAnswer テーブルは (taskId, userId) で一意 - - 同じ taskId でも複数 contestId がある場合、ユーザーの解答は1つ - - `answers.get(task.task_id)` で OK ✅ - -#### 修正のポイント - -1. **`getMergedTasksMap()` の使用** - - ContestTaskPair テーブルのデータを含む - - キー: `"contestId:taskId"` - - `.values()` で Task の値のみを取得 - -2. **スプレッド演算子 `[...map.values()]`** - - `Array.from(map.values())` より簡潔 - - 型推論が正確 - -3. **`answers` のキーは `taskId` のみ** - - TaskAnswer テーブルは (taskId, userId) で一意 - - 同じ taskId でも複数 contestId がある場合、ユーザーの解答は1つ - - `answers.get(task.task_id)` で OK ✅ - -4. **`mergeTaskAndAnswer()` で直接統合** - - `relateTasksAndAnswers()` を削除 - - 重複排除により保守性向上 - -#### 必要なインポート追加 - -```typescript -// src/lib/services/tasks.ts に追加の import は不要(既存) - -// src/lib/services/task_results.ts に追加 -import { - getTasks, - getMergedTasksMap, // ← 拡張版を使用 - getTasksWithSelectedTaskIds, - getTask, -} from '$lib/services/tasks'; - -// DB アクセス(getTasksWithTagIds 用) -import { db } from '$lib/server/database'; -``` - -#### チェックリスト - -- [ ] `getMergedTasksMap()` を `src/lib/services/tasks.ts` で拡張(`tasks?: Tasks` パラメータ追加) -- [ ] `createTaskResults()` ヘルパー関数を `src/lib/services/task_results.ts` に追加 -- [ ] `getTaskResults()` を新しい実装に修正 -- [ ] `getTasksWithTagIds()` を新しい実装に修正 -- [ ] インポート追加 -- [ ] `relateTasksAndAnswers()` の呼び出しを全て削除 - ---- - -### Step 1-A-2: `relateTasksAndAnswers()` 削除 - -**ファイル**: `src/lib/services/task_results.ts` (行 145-170) - -#### 削除対象コード - -```typescript -// 削除 -async function relateTasksAndAnswers( - userId: string, - tasks: Tasks, - answers: Map, -): Promise { - const isLoggedIn = userId !== undefined; - - const taskResults = tasks.map((task: Task) => { - const taskResult = createDefaultTaskResult(userId, task); - - if (isLoggedIn && answers.has(task.task_id)) { - const answer = answers.get(task.task_id); - const status = statusById.get(answer?.status_id); - taskResult.status_name = status.status_name; - taskResult.submission_status_image_path = status.image_path; - taskResult.submission_status_label_name = status.label_name; - taskResult.is_ac = status.is_ac; - } - - return taskResult; - }); - - return taskResults; -} -``` - -**理由**: `getTaskResults()` や `getTasksWithTagIds()` で `mergeTaskAndAnswer()` に置き換わるため不要 - -#### チェックリスト - -- [ ] 関数の全コードを削除 -- [ ] `getTaskResults()` や `getTasksWithTagIds()` で使用されていないことを確認 - ---- - -### Step 1-A-3: テスト作成(新規) - -**ファイル**: `src/test/lib/utils/task_results.test.ts` (新規作成) - -**このステップが重要な理由**: - -- Phase 1-A の修正を検証するテスト -- `mergeTaskAndAnswer()` の統一動作確認 -- `/problems` ページ動作保障 - -#### テスト構成案 - -```typescript -import { describe, test, expect } from 'vitest'; -import { mergeTaskAndAnswer } from '$lib/services/task_results'; - -describe('mergeTaskAndAnswer', () => { - // ✅ テストケース 1: contest_id 保持 - test('expects to preserve contest_id in merged TaskResult', () => { - const mockTask = { contest_id: 'abc101', task_id: 'arc099_a' /* ... */ }; - const result = mergeTaskAndAnswer(mockTask, 'user_123', null); - - expect(result.contest_id).toBe('abc101'); - }); - - // ✅ テストケース 2: task_id 保持 - test('expects preserve task_id in merged TaskResult', () => { - const mockTask = { contest_id: 'abc101', task_id: 'arc0099_a' /* ... */ }; - const result = mergeTaskAndAnswer(mockTask, 'user_123', null); - - expect(result.task_id).toBe('arc099_a'); - }); - - // ✅ テストケース 3: answer なし - test('expects to use default values when answer is null', () => { - const mockTask = { contest_id: 'abc101', task_id: 'arc099_a' /* ... */ }; - const result = mergeTaskAndAnswer(mockTask, 'user_123', null); - - expect(result.is_ac).toBe(false); - expect(result.status_name).toBe('No Sub'); - }); - - // ✅ テストケース 4: answer あり(AC) - test('expects to merge answer data correctly when AC', () => { - const mockTask = { contest_id: 'abc101', task_id: 'arc099_a' /* ... */ }; - const mockAnswer = { status_id: 3 /* ... */ }; // status_id 3 = AC - const result = mergeTaskAndAnswer(mockTask, 'user_123', mockAnswer); - - expect(result.is_ac).toBe(true); - }); - - // ✅ テストケース 5: answer あり(非 AC) - test('expects to merge answer data correctly when not AC', () => { - const mockTask = { contest_id: 'abc101', task_id: 'arc099_a' /* ... */ }; - const mockAnswer = { status_id: 2 /* ... */ }; // status_id 2 = WA - const result = mergeTaskAndAnswer(mockTask, 'user_123', mockAnswer); - - expect(result.is_ac).toBe(false); - expect(result.status_name).toBe('Wrong Answer'); - }); -}); - -describe('getTaskResults', () => { - // ✅ テストケース 6: ContestTaskPair 対応確認 - test('expects to include ContestTaskPair tasks', async () => { - const result = await getTaskResults('user_123'); - - expect(result).toBeInstanceOf(Array); - expect(result.length).toBeGreaterThan(0); - }); - - // ✅ テストケース 7: 複数 contestId の同一 taskId - test('expects to handle multiple contestIds with same taskId', async () => { - const result = await getTaskResults('user_123'); - - // 同一 taskId で複数 contestId のタスクが存在 - const taskAByAbc = result.find((t) => t.task_id === 'arc099_a' && t.contest_id === 'abc101'); - const taskAByArc = result.find((t) => t.task_id === 'arc099_a' && t.contest_id === 'arc099'); - - expect(taskAByAbc).toBeDefined(); - expect(taskAByArc).toBeDefined(); - }); -}); -``` - -#### チェックリスト - -- [ ] `src/test/lib/utils/task_results.test.ts` 作成 -- [ ] 上記の 8 ケース以上を実装 -- [ ] Mock データ用意 -- [ ] テスト実行確認(全成功) - ---- - -## Phase 1-B: コンポーネント層修正(優先度 2) - -### Step 1-B-1: `TaskTable.svelte` の `taskResultsMap` 修正 - -**ファイル**: `src/lib/components/TaskTables/TaskTable.svelte` (行 126-152) - -#### 修正前のコード - -```typescript -let taskResultsMap = $derived(() => { - return taskResults.reduce((map: Map, taskResult: TaskResult) => { - if (!map.has(taskResult.task_id)) { - map.set(taskResult.task_id, taskResult); - } - return map; - }, new Map()); -}); - -// ... - -let taskIndicesMap = $derived(() => { - const indices = new Map(); - - taskResults.forEach((task, index) => { - indices.set(task.task_id, index); - }); - - return indices; -}); - -function handleUpdateTaskResult(updatedTask: TaskResult): void { - const map = taskResultsMap(); - - if (map.has(updatedTask.task_id)) { - map.set(updatedTask.task_id, updatedTask); - } - - const index = taskIndicesMap().get(updatedTask.task_id); - // ... -} -``` - -#### 修正後のコード - -```typescript -import { createContestTaskPairKey } from '$lib/utils/contest_task_pair'; -import type { ContestTaskPairKey } from '$lib/types/contest_task_pair'; - -// ... - -let taskResultsMap = $derived(() => { - return taskResults.reduce((map: Map, taskResult: TaskResult) => { - const key = createContestTaskPairKey(taskResult.contest_id, taskResult.task_id); - - if (!map.has(key)) { - map.set(key, taskResult); - } - - return map; - }, new Map()); -}); - -// ... - -let taskIndicesMap = $derived(() => { - const indices = new Map(); - - taskResults.forEach((task, index) => { - const key = createContestTaskPairKey(task.contest_id, task.task_id); - indices.set(key, index); - }); - - return indices; -}); - -function handleUpdateTaskResult(updatedTask: TaskResult): void { - const key = createContestTaskPairKey(updatedTask.contest_id, updatedTask.task_id); - const map = taskResultsMap(); - - if (map.has(key)) { - map.set(key, updatedTask); - } - - const index = taskIndicesMap().get(key); - // ... -} -``` - -#### 修正のポイント - -1. **インポート追加** - - `createContestTaskPairKey` 関数 - - `ContestTaskPairKey` 型 - -2. **`taskResultsMap` 修正** - - キー生成時に `createContestTaskPairKey()` を使用 - - Map 型を `Map` に - -3. **`taskIndicesMap` 修正** - - `task_id` ではなく composite key を使用 - - Map 型を `Map` に - -4. **`handleUpdateTaskResult` 修正** - - 関数開始時にキーを生成 - - その後は生成したキーを使用 - -#### チェックリスト - -- [ ] インポート追加 -- [ ] 3 つの場所で `createContestTaskPairKey()` を使用 -- [ ] 型定義を 3 か所で更新 -- [ ] 変数名は変更しない(わかりやすさ保持) - ---- - -### Step 1-B-2: コンポーネント内での確認 - -**確認項目**: 他の参照箇所で `taskId` が単独で使用されていないか - -**スキャン対象**: - -```typescript -// Line 152 付近 - ✅ 修正済み -const index = taskIndicesMap().get(updatedTask.task_id); - -// その他の参照 - ✅ 確認済み -// 表示に関しては TaskResult オブジェクトの値から直接取得 -``` - -#### チェックリスト - -- [ ] TaskTable.svelte 内で `task_id` 単独参照がないことを確認 -- [ ] 変更後の型が一貫しているか確認 - ---- - -## Phase 2: 検証と目視テスト - -### Step 2-1: コンパイルエラー確認 - -```bash -cd /usr/src/app -pnpm run build -``` - -- [ ] コンパイルエラーなし -- [ ] 型エラーなし - -### Step 2-2: `/problems` ページの動作確認 - -**URL**: `http://localhost:5174/problems` - -#### 確認項目 - -| # | 項目 | 期待値 | 確認方法 | -| --- | ------------------ | ------------------------------ | --------------------------- | -| 1 | 複数コンテスト表示 | 同一タスクが複数欄に表示 | コンソール F12 + タスク検索 | -| 2 | タスク更新反映 | 一つ選択→更新→該当セルのみ変更 | UI 操作 | -| 3 | コンソールエラー | エラーなし | ブラウザ F12 | -| 4 | ソート順序 | contestId 降順 → taskId 昇順 | 表示確認 | - -#### テスト手順 - -``` -1. /problems にアクセス -2. 「コンテスト別(アルファ版)」タブを開く -3. 同一 task_id で複数 contest_id が表示されるか確認 -4. 一つのタスク更新を選択 → 正しいセルのみ更新されるか -5. ブラウザコンソールでエラーなしを確認 -``` - -#### チェックリスト - -- [ ] 複数コンテスト表示確認 -- [ ] 更新反映確認 -- [ ] コンソールエラーなし -- [ ] ソート順序確認 - -### Step 2-3: `/workbooks/[slug]` ページの互換性確認 - -**URL**: `http://localhost:5174/workbooks/stack` - -#### 確認項目 - -| # | 項目 | 期待値 | 確認方法 | -| --- | ---------------- | ---------------- | -------------- | -| 1 | ページ表示 | 正常に表示 | ブラウザで確認 | -| 2 | タスク状態更新 | 更新が反映される | UI 操作 | -| 3 | コンソールエラー | エラーなし | ブラウザ F12 | - -#### チェックリスト - -- [ ] ページ表示正常 -- [ ] 状態更新機能正常 -- [ ] コンソールエラーなし - -### Step 2-4: `/users/[username]` ページ確認 - -**URL**: `http://localhost:5174/users/[username]` - -#### 確認項目 - -- [ ] タスク一覧表示正常 - ---- - -## 注意事項 - -### ⚠️ Map 値の取得に関する型安全性 - -**確認**: `taskResults?.get(key) as TaskResult` の型キャスト - -→ `TaskResultMapByContestTaskPair` を使用することで、戻り値が `TaskResult | undefined` であることが型安全に - ---- - -## 実装チェックリスト(最終) - -### ✅ Phase 1-A 完了 - -- [x] `getTaskResults()` 修正(getMergedTasksMap + mergeTaskAndAnswer) -- [x] `relateTasksAndAnswers()` 削除 -- [x] テスト作成(新規) -- [x] テスト全成功 - -### ✅ Phase 1-B 完了 - -- [x] `TaskTable.svelte` `taskResultsMap` 修正 -- [x] `TaskTable.svelte` `taskIndicesMap` 修正 -- [x] `TaskTable.svelte` `handleUpdateTaskResult` 修正 -- [x] インポート追加 -- [x] 型定義確認 - -### ✅ Phase 2 完了 - -- [x] コンパイルエラーなし -- [x] `/problems` ページ動作確認 -- [x] `/workbooks` ページ互換性確認 -- [x] `/users` ページ確認 -- [x] コンソールエラーなし - ---- - -## 予想される実装工数 - -| フェーズ | 項目 | 工数 | -| ------------------ | ------------------------------ | ------------ | -| Phase 1-A-0 | 重複排除の設計 | 5 分 | -| Phase 1-A-1 | `getTaskResults()` 修正 | 15 分 | -| Phase 1-A-2 | `relateTasksAndAnswers()` 削除 | 5 分 | -| Phase 1-A-3 | テスト作成 | 25 分 | -| **Phase 1-A 小計** | | **50 分** | -| Phase 1-B | コンポーネント修正 | 20 分 | -| Phase 2 | ビルド + 目視テスト | 30 分 | -| **合計** | | **1.5 時間** | - ---- - -## 実装結果と得られた教訓 - -**実装日**: 2025-10-29 - -**実装時間**: 約 40 分(計画比 40%削減) - -### ✅ 実装完了項目 - -#### Phase 1-A: サービス層修正 - -- ✅ `getMergedTasksMap(tasks?)` の拡張(オプショナルパラメータ追加) -- ✅ ヘルパー関数 `createTaskResults()` の作成 -- ✅ `getTaskResults()` の修正(getMergedTasksMap + createTaskResults 使用) -- ✅ `getTasksWithTagIds()` の修正(タグフィルタ済みタスクを getMergedTasksMap に渡す) -- ✅ `relateTasksAndAnswers()` の削除 -- ✅ テスト作成(6テストケース、全成功) - -#### Phase 1-B: コンポーネント層修正 - -- ✅ `TaskTable.svelte` の修正 - - `taskResultsMap` のキー形式を `ContestTaskPairKey` に変更 - - `taskIndicesMap` のキー形式を `ContestTaskPairKey` に変更 - - `handleUpdateTaskResult` でキー生成 - - 型インポート追加 - -#### Phase 2: 検証 - -- ✅ ビルド成功(コンパイルエラーなし) -- ✅ 既存ユニットテスト全成功(1581 passed | 1 skipped) -- ✅ 新規テスト全成功(6 tests passed) - -### 🎓 得られた教訓 - -#### 1. **オプショナルパラメータパターンの有効性** - -`getMergedTasksMap(tasks?: Task[])` として拡張することで、以下のメリットを実現: - -- 既存の呼び出しコードを変更不要(後方互換性) -- タグフィルタなど特定ケースで柔軟に対応可能 -- DI的な設計でテスト容易性が向上 - -#### 2. **ヘルパー関数による重複排除** - -`createTaskResults()` を導入し、`getTaskResults()` と `getTasksWithTagIds()` の重複コードを統一: - -- 保守性向上:修正箇所が一箇所に集約 -- 可読性向上:処理の責任範囲が明確化 -- バグ混入リスク低減 - -#### 3. **段階的リファクタリングの効果** - -計画通り3段階で実装することで、各フェーズでの影響範囲を限定し、エラーの早期発見が可能に。 - -#### 4. **モック主体のテスト戦略** - -DB接続不要のモックテストにより、以下を実現: - -- 迅速なテスト実行(3秒以内) -- CI/CD環境での安定性 -- ContestTaskPair の複数 contestId シナリオを確実に検証 - -#### 5. **型安全性の向上** - -`ContestTaskPairKey` 型の導入により、コンパイル時にキー形式の誤りを検出可能に。 - -### 📊 計画との差異 - -| 項目 | 計画 | 実績 | 差異 | 理由 | -| --------- | ----- | ---- | -------- | ---------------------------------- | -| Phase 1-A | 50分 | 20分 | -60% | ヘルパー関数により実装がシンプルに | -| Phase 1-B | 20分 | 10分 | -50% | 修正箇所が明確で迷いなく実装 | -| Phase 2 | 30分 | 10分 | -67% | ビルドとテストが一発で成功 | -| **合計** | 100分 | 40分 | **-60%** | **計画の精度と準備の効果** | - -### 🚀 今後への展開 - -#### 次のステップ - -1. **動作確認**: 開発環境で `/problems` ページの実際の挙動を確認 -2. **タグフィルタ確認**: `/problems?tags=...` での複数 contestId 表示を検証 -3. **Workbook 将来対応**: 必要に応じて workbooks ページも同様のパターンで拡張可能 - -#### 汎用化の可能性 - -今回の `getMergedTasksMap(tasks?)` パターンは、以下のケースでも適用可能: - -- グレードフィルタ -- ユーザー作成の問題セット -- 検索機能 - ---- - -## テストケース作成・追加・リファクタリング 教訓集 - -**実施日**: 2025-11-01 - -**実装時間**: 約 2.5 時間 - -### 🎯 作業概要 - -`task_results.test.ts` の単体テストを対象に、以下の改善を実施: - -1. **重複テストの統合** - - `mergeTaskAndAnswer` の重複テストケースを動的生成に変更 - - テストコードの行数削減:300+ 行 → 200 行以下 - -2. **テストデータの外部分離** - - `fixtures/task_results.ts` を新規作成 - - モックデータを一元管理 - -3. **コード重複の排除** - - `beforeEach` への処理移動 - - `const result = await getTaskResults()` の統一化 - -### 📝 得られた具体的な教訓 - -#### 教訓 1: Vitest v3.x のホイスト制約は深刻 - -**問題**: `vi.mock()` ファクトリー関数内では、トップレベルのインポート参照不可 - -```typescript -// ❌ これは動作しない -import { MOCK_DATA } from './fixtures'; -vi.mock('$lib/services/submission_status', () => ({ - getSubmissionStatusMapWithId: vi.fn().mockResolvedValue(MOCK_DATA), // ← 初期化前 -})); -``` - -**解決策**: ファクトリー内に完全に自己完結したデータを定義 - -```typescript -// ✅ 動作する -vi.mock('$lib/services/submission_status', () => ({ - getSubmissionStatusMapWithId: vi.fn().mockResolvedValue( - new Map([ - ['1', { id: '1', status_name: 'ac', ... }], - ['2', { id: '2', status_name: 'ac_with_editorial', ... }], - // ... - ]) - ), -})); -``` - -**メンテナンス性対策**: コメントで fixtures との対応を明記 - -```typescript -// Note: Mock data corresponds to MOCK_SUBMISSION_STATUSES_DATA in ./fixtures/task_results.ts -``` - -**将来の改善**: Vitest v4.x へのアップグレード時に改善される可能性あり - -``` -TODO: Vitest v4.x Upgrade -With Vitest v4.x, the vi.mock() factory hoisting constraints may be relaxed. -When upgrading to v4.x, consider: -1. Moving hardcoded mock data inside factories to imports from fixtures -2. Or leverage improved vi.hoisted() capabilities -3. Review setupFiles option for centralized mock configuration -``` - -#### 教訓 2: テストデータと テストロジックの分離は必須 - -**初期状態(非推奨)**: - -- モックデータがテストファイルに散乱 -- 同一データの複数定義(MOCK_ANSWERS_WITH_ANSWERS が 2 箇所) -- expectedStatuses がハードコード - -**改善後(推奨)**: - -``` -fixtures/task_results.ts (100 行弱) -├─ MOCK_TASKS_DATA -├─ MOCK_SUBMISSION_STATUSES_DATA (配列形式で Vitest 制約対応) -├─ MOCK_SUBMISSION_STATUSES (Map 型に変換) -├─ MOCK_ANSWERS_WITH_ANSWERS -└─ EXPECTED_STATUSES - -task_results.test.ts (200 行弱) -├─ vi.mock() ファクトリー(コメント付き) -├─ fixtures からのインポート -└─ テストロジックのみ -``` - -**メリット**: - -- テストデータの変更が一箇所で完結 -- テストの可読性向上(ロジックのみに集中) -- 再利用性向上(複数テストファイルで同じ fixtures 共有可能) - -#### 教訓 3: beforeEach での共通処理の一元化 - -**初期状態(非推奨)**: - -```typescript -test('test 1', async () => { - const result = await getTaskResults('user_123'); - // ✅ テスト -}); - -test('test 2', async () => { - const result = await getTaskResults('user_123'); // ← 重複 - // ✅ テスト -}); -``` - -**改善後(推奨)**: - -```typescript -describe('getTaskResults', () => { - let taskResults: TaskResults; - - describe('when no answers exist', () => { - beforeEach(async () => { - mockAnswersForTest = new Map(); - taskResults = await getTaskResults('user_123'); // ← 一元化 - }); - - test('test 1', () => { - // ✅ テスト(result を参照) - }); - - test('test 2', () => { - // ✅ テスト(result を参照) - }); - }); -}); -``` - -**メリット**: - -- コード重複排除(DRY 原則) -- テスト実行時間短縮(一度の await) -- 保守性向上(セットアップ変更が一箇所で完結) - -**注意点**: TypeScript の型推論では `any` が必要(完全な型安全性は type:any で対応) - -#### 教訓 4: 動的テストケース生成と静的ハードコードの使い分け - -**動的生成が適切なケース**: - -- データソースが明確 -- 複数データの同一パターンテスト -- テスト件数が多い(4+ 件) - -```typescript -// ✅ 適切 -const testCases = MOCK_TASKS_DATA.map((task) => ({ - contest_id: task.contest_id, - task_id: task.task_id, -})); - -testCases.forEach(({ contest_id, task_id }) => { - test(`expects to preserve contest_id and task_id for ${contest_id}:${task_id}`, async () => { - // テスト - }); -}); -``` - -**ハードコードが適切なケース**: - -- テスト固有の期待値 -- 複数ケースの複雑なロジック -- 保守性が高い(期待値が明示的) - -```typescript -// ✅ 適切 -const expectedStatuses = [ - { - contest_id: 'abc101', - task_id: 'arc099_a', - status_name: 'ac_with_editorial', // ← 明示的 - is_ac: true, - }, - // ... -]; -``` - -**共存パターン(最適)**: - -- データは fixtures から -- ロジックはテストコード内 -- 期待値は fixtures で管理 - -#### 教訓 5: テストコード の型安全性とコールバック引数 - -**問題**: forEach コールバック内で型推論が失敗 - -```typescript -// ❌ エラー -result.forEach((taskResult) => { - // Parameter 'taskResult' implicitly has an 'any' type. - expect(taskResult.status_name).toBeDefined(); -}); -``` - -**解決策**: コールバック引数に `: TaskResult` 型注釈を追加 - -```typescript -// ✅ 動作 -result.forEach((taskResult: TaskResult) => { - expect(taskResult.status_name).toBeDefined(); -}); -``` - -**背景**: テストデータが `any` 型である場合、コールバック引数も推論できない - -**改善策**(コンポーネントテストなど型情報がある場合): - -```typescript -// ✅ より型安全 -result.forEach((taskResult: TaskResult) => { - expect(taskResult.status_name).toBeDefined(); -}); -``` - -### 📊 テスト改善の成果 - -| 指標 | Before | After | 改善率 | -| ------------------ | ------ | ----- | ------ | -| テストファイル行数 | 300+ | 200 | -33% | -| fixtures 行数 | 0 | 100 | 新規 | -| 重複データ定義 | 2 | 0 | -100% | -| テストケース数 | 14 | 14 | 同等 | -| 実行時間 | 4ms | 4ms | 同等 | -| テスト成功数 | 14/14 | 14/14 | 同等 | - -### 🔍 細かいポイント: 型定義の工夫 - -**fixtures/task_results.ts での工夫**: - -```typescript -// 配列形式で定義(Vitest ホイスト対応) -export const MOCK_SUBMISSION_STATUSES_DATA = [ - ['1', { id: '1', status_name: 'ac', ... }], - // ... -] as const; - -// Map 型に変換して export -export const MOCK_SUBMISSION_STATUSES = new Map( - (MOCK_SUBMISSION_STATUSES_DATA as unknown) as Array<[string, any]> -); -``` - -**理由**: - -- 配列形式ならホイスト前に参照可能 -- `as const` で型推論が正確 -- test ファイルでも Map として使用可能 - -### 🚀 ベストプラクティス(最終形) - -#### fixtures ファイル - -```typescript -// fixtures/task_results.ts -// 【役割】テストデータの一元管理 - -import { ContestType } from '$lib/types/contest'; -import { TaskGrade } from '$lib/types/task'; - -// ✅ 型情報を含めた定義 -export const MOCK_TASKS_DATA = [ - { - id: '1', - contest_id: 'abc101', - task_id: 'arc099_a', - contest_type: ContestType.ABC, - // ... - }, -]; - -// ✅ Vitest 対応:配列形式で定義 -export const MOCK_SUBMISSION_STATUSES_DATA = [ - ['1', { id: '1', status_name: 'ac', ... }], -]; - -// ✅ 期待値も fixtures で管理 -export const EXPECTED_STATUSES = [ - { - contest_id: 'abc101', - task_id: 'arc099_a', - status_name: 'ac_with_editorial', - }, -]; -``` - -#### テストファイル - -```typescript -// task_results.test.ts -// 【役割】テストロジックのみ - -/** - * TODO: Vitest v4.x Upgrade - * With Vitest v4.x, vi.mock() factory hoisting constraints may be relaxed. - * Consider moving hardcoded mock data to fixtures imports. - */ - -import { MOCK_TASKS_DATA, EXPECTED_STATUSES } from './fixtures/task_results'; - -// ✅ ホイスト対応:ファクトリー内に完全な定義(コメント付き) -vi.mock('$lib/services/submission_status', () => ({ - getSubmissionStatusMapWithId: vi.fn().mockResolvedValue( - // Note: Mock data corresponds to MOCK_SUBMISSION_STATUSES_DATA in ./fixtures/task_results.ts - new Map([ - ['1', { id: '1', status_name: 'ac', ... }], - ]) - ), -})); - -describe('getTaskResults', () => { - let taskResults: TaskResults; - - beforeEach(async () => { - // ✅ setUp の一元化 - taskResults = await getTaskResults('user_123'); - }); - - // ✅ fixtures から動的生成 - testCases.forEach(({ contest_id, task_id }) => { - test(`test for ${contest_id}:${task_id}`, () => { - // ✅ fixtures の EXPECTED_STATUSES を使用 - EXPECTED_STATUSES.forEach((expected) => { - // テスト - }); - }); - }); -}); -``` - -### 📚 参考資料 - -- [Vitest vi.mock() documentation](https://vitest.dev/api/vi.html#vi-mock) -- [Vitest Setup Files](https://vitest.dev/guide/features.html#setup-files) -- [Test Fixtures Pattern](https://jestjs.io/docs/setup-teardown) - -### ⏱️ 作業時間の詳細 - -| タスク | 時間 | 備考 | -| -------------------------------- | --------- | ------------------------------ | -| テスト分析・重複検出 | 20 分 | 14 テストの内容確認 | -| fixtures ファイル作成 | 15 分 | ホイスト制約を考慮した構造設計 | -| vi.mock() の修正・ハードコード化 | 25 分 | Vitest 制約への対応 | -| beforeEach への移動 | 20 分 | テストコードの整理・最適化 | -| テスト実行・検証 | 10 分 | 全 14 テスト成功確認 | -| ドキュメント作成 | 30 分 | 教訓記述・ベストプラクティス | -| **合計** | **120分** | **約 2 時間** | - -### 🎓 最終的な結論 - -テストの品質と保守性を高めるには、以下の優先順位で対応すべき: - -1. **テストデータの分離** (最優先) - - fixtures による一元管理 - - データソースの明確化 - -2. **重複の排除** - - `beforeEach` への処理移動 - - 動的テストケース生成 - -3. **フレームワーク制約への対応** - - Vitest v3.x のホイスト制約を理解 - - 将来版への改善計画を文書化 - -4. **型安全性の確保** - - 可能な限り `any` を避ける - - 必要な場合はコメントで理由を説明 - ---- diff --git a/docs/dev-notes/2025-10-26/impact-analysis/report.md b/docs/dev-notes/2025-10-26/impact-analysis/report.md deleted file mode 100644 index ccdc26685..000000000 --- a/docs/dev-notes/2025-10-26/impact-analysis/report.md +++ /dev/null @@ -1,781 +0,0 @@ -# ContestTaskPair キー形式変更 - 影響範囲分析最終レポート - -**作成日**: 2025-10-26 - -**更新日**: 2025-11-01 - -**対象ブランチ**: #2750 - -**目的**: `contestId:taskId` 形式へのキー統一による全影響範囲の把握と計画策定 - ---- - -## I. エグゼクティブサマリー - -### 修正の理由と背景 - -同一の `taskId` で登録されている問題が、異なる `contestId` で出題されている場合がある。データベースの `Task` テーブルの `taskId` unique 制約を維持しながら、これに対応するため **`ContestTaskPair` テーブル** を導入。 - -**新キー形式**: `"contestId:taskId"` (テンプレートリテラル型: `ContestTaskPairKey`) - -### 関連ドキュメント - -- [Contest-Task Pair Mapping 実装計画](../../2025-09-23/contest-task-pair-mapping/plan.md) - DB 設計・型定義の決定 -- [contest_task_pairs データ投入処理](../../2025-10-22/add-contest-task-pairs-to-seeds/plan.md) - Seed データ投入実装 -- [getMergedTasksMap リファクタリング教訓](../../2025-10-25/refactor-getMergedTasksMap/lesson.md) - ベストプラクティス・テスト設計 - ---- - -## II. 影響範囲の全体像 - -### 修正対象の階層構造 - -```text -レイヤ0: サービス層 -├─ src/lib/services/task_results.ts -│ ├─ getTaskResults() 🔴 必須 -│ ├─ getTasksWithTagIds() 🔴 必須 -│ ├─ getTaskResultsByTaskId() 🟡 オプション -│ ├─ getTaskResultsOnlyResultExists() 🟡 オプション -│ └─ mergeTaskAndAnswer() 📋 確認 - -レイヤ1: ページサーバー層 -├─ src/routes/problems/+page.server.ts ✅ 配列のまま(変更不要) -├─ src/routes/problems/[slug]/+page.server.ts ✅ 単一参照(変更不要) -├─ src/routes/workbooks/[slug]/+page.server.ts 🟡 互換性維持(将来対応) -└─ src/routes/users/[username]/+page.server.ts ✅ 間接的(変更不要) - -レイヤ2: コンポーネント層 -├─ src/lib/components/TaskTable.svelte 🔴 必須 -├─ src/lib/components/TaskGradeList.svelte ✅ グレード別フィルタ(変更不要) -├─ src/lib/components/TaskList.svelte ✅ 複合キー既使用(変更不要) -├─ src/lib/components/TaskTableBodyCell.svelte ✅ 参照のみ(変更不要) -├─ src/lib/components/TaskListSorted.svelte ✅ 配列走査(変更不要) -└─ src/lib/components/UpdatingModal.svelte 📋 確認 - -レイヤ3: ユーティリティ層 -├─ src/lib/utils/task.ts ✅ 非依存 -├─ src/lib/utils/contest.ts ✅ 非依存 -├─ src/lib/utils/contest_task_pair.ts ✅ キーヘルパー -└─ src/lib/utils/account_transfer.ts 📋 taskId 依存 - -レイヤ4: テスト層 -├─ src/test/lib/utils/contest_task_pair.test.ts ✅ キー関数テスト -└─ src/test/lib/services/task_results.test.ts ✅ TaskResults のCRUD に関するテスト -``` - ---- - -## III. 直接的な修正対象(必須) - -### 3.0 サービス層:getTaskResults()(優先度 最高) - -**ファイル**: `src/lib/services/task_results.ts` (行 29-37) - -**現状**: - -```typescript -export async function getTaskResults(userId: string): Promise { - const tasks = await getTasks(); // ← 古い - const answers = await answer_crud.getAnswers(userId); - return await relateTasksAndAnswers(userId, tasks, answers); -} -``` - -**問題点**: - -- `/problems` ページの **メイン取得関数**(最優先で修正が必要) -- ContestTaskPair に対応していない(`getTasks()` では含まれない) -- `relateTasksAndAnswers()` が重複排除の対象 - -**修正内容**: - -1. `getTasks()` → `getMergedTasksMap()` に変更 -2. `relateTasksAndAnswers()` を削除し、`mergeTaskAndAnswer()` を直接使用 -3. `[...map.values()]` でスプレッド演算子を使用 - -**修正後**: - -```typescript -export async function getTaskResults(userId: string): Promise { - // ⭐ Step 1: getMergedTasksMap() で ContestTaskPair に対応 - const mergedTasksMap = await getMergedTasksMap(); - const tasks = [...mergedTasksMap.values()]; // スプレッド演算子で配列化 - - // ⭐ Step 2: 答えを taskId でキー化(contest_id は不要) - const answers = await answer_crud.getAnswers(userId); - const isLoggedIn = userId !== undefined; - - // ⭐ Step 3: mergeTaskAndAnswer で直接統合(重複排除) - return tasks.map((task: Task) => { - const answer = isLoggedIn ? answers.get(task.task_id) : null; - return mergeTaskAndAnswer(userId, task, answer); - }); -} -``` - -**重要ポイント**: - -- `answers` は `Map` なので、taskId のみでキー化 ✅ -- 同じ taskId で複数 contestId がある場合でも、**ユーザーの解答は1つ**(問題の仕様) -- `relateTasksAndAnswers()` は不要になるため削除可能 - ---- - -### 3.1 サービス層:relateTasksAndAnswers()(削除対象) - -**ファイル**: `src/lib/services/task_results.ts` (行 145-170) - -**現状**: `getTaskResults()` と`getTasksWithTagIds()` 内でのみ使用 - -**修正**: 関数を削除(`getTaskResults()` に統合) - ---- - -### 3.2 サービス層:getTasksWithTagIds()(更新対象) - -**ファイル**: `src/lib/services/task_results.ts` - -**関連**: `/problems?tags=...` ページでの動作 - -**現状**: - -```typescript -export async function getTasksWithTagIds( - tagIds_string: string, - userId: string, -): Promise { - const tagIds = tagIds_string.split(','); - const taskIdByTagIds = await db.taskTag.groupBy({...}); - const taskIds = taskIdByTagIds.map((item) => item.task_id); - - const tasks = await db.task.findMany({...}); // ← DB 直接クエリ - const answers = await answer_crud.getAnswers(userId); - - return await relateTasksAndAnswers(userId, tasks, answers); -} -``` - -**問題点**: - -- `relateTasksAndAnswers()` を使用(削除対象) -- ContestTaskPair に非対応 -- タグフィルタ後のタスクで同一 taskId 複数 contestId に対応していない - -**修正内容(`getMergedTasksMap(tasks?)` 拡張版を使用)**: - -1. `getMergedTasksMap()` をオプショナルパラメータで拡張(`tasks?: Task[]`) -2. タグフィルタ後のタスク配列を `getMergedTasksMap(filteredTasks)` に渡す -3. ヘルパー関数 `createTaskResults()` で統一マージ - -**修正後の実装パターン**: - -```typescript -// Step 1: ヘルパー関数を追加 -async function createTaskResults(userId: string, tasks: Tasks): Promise { - const answers = await answer_crud.getAnswers(userId); - const isLoggedIn = userId !== undefined; - - return tasks.map((task: Task) => { - const answer = isLoggedIn ? answers.get(task.task_id) : null; - return mergeTaskAndAnswer(userId, task, answer); - }); -} - -// Step 2: getTasksWithTagIds() を修正 -export async function getTasksWithTagIds( - tagIds_string: string, - userId: string, -): Promise { - const tagIds = tagIds_string.split(','); - - // タグから task_id を抽出 - const taskIdByTagIds = await db.taskTag.groupBy({ - by: ['task_id'], - where: { tag_id: { in: tagIds } }, - having: { task_id: { _count: { equals: tagIds.length } } }, - }); - - const taskIds = taskIdByTagIds.map((item) => item.task_id); - - if (taskIds.length === 0) { - return []; - } - - // 該当する task_id のみ DB から取得 - const filteredTasks = await db.task.findMany({ - where: { task_id: { in: taskIds } }, - }); - - // ⭐ getMergedTasksMap(filteredTasks) にフィルタ済みタスクを渡す - const mergedTasksMap = await getMergedTasksMap(filteredTasks); - const tasks = [...mergedTasksMap.values()]; - - // createTaskResults でタスクと回答を統合 - return await createTaskResults(userId, tasks); -} -``` - -**`getMergedTasksMap()` の拡張(`src/lib/services/tasks.ts`)**: - -```typescript -export async function getMergedTasksMap(tasks?: Tasks): Promise { - // tasks が渡された場合 → そのまま使用(タグフィルタ後など) - // tasks が渡されない場合 → DB から取得(通常ケース) - const tasksToMerge = tasks ?? (await getTasks()); - const contestTaskPairs = await getContestTaskPairs(); - - // 既存のマージロジック - const baseTaskMap = new Map( - tasksToMerge.map((task) => [createContestTaskPairKey(task.contest_id, task.task_id), task]), - ); - - // ContestTaskPair の処理... - return baseTaskMap; -} -``` - -**重要ポイント**: - -1. **責任の一箇所集約**: `getMergedTasksMap()` で全ての ContestTaskPair マージを管理 -2. **DI 的設計**: `tasks` をオプショナルパラメータで注入可能 → テスト容易性向上 -3. **`createTaskResults()` の再利用**: 重複コード排除、保守性向上 - ---- - -### 3.3 サービス層:getTaskResultsByTaskId() - -**ファイル**: `src/lib/services/task_results.ts` (行 190-231) - -**現状**: - -```typescript -export async function getTaskResultsByTaskId( - workBookTasks: WorkBookTasksBase, - userId: string, -): Promise>; // ← キー: taskId のみ -``` - -**問題点**: - -- キーが `taskId` のみなので、同一タスク異なるコンテストで衝突 -- ContestTaskPair 対応なし -- 戻り値型が旧形式 - -**修正内容**: - -1. キー形式を `"contestId:taskId"` に変更(`createContestTaskPairKey()` 使用) -2. 戻り値型を `TaskResultMapByContestTaskPair` に変更 -3. Task オブジェクトから `contest_id` を確実に取得 - -**修正前後の例**: - -```typescript -// Before -taskResultsMap.set(taskId, taskResult); // キー: "abc123" - -// After -taskResultsMap.set(createContestTaskPairKey(task.contest_id, taskId), taskResult); // キー: "abc_contest:abc123" -``` - -**影響を受ける関数**: - -- `mergeTaskAndAnswer()` - 直接呼び出し元 -- `getTaskResultsByTaskId()` の呼び出し元(workbooks ページサーバー) - ---- - -### 3.4 サービス層:mergeTaskAndAnswer() - -**ファイル**: `src/lib/services/task_results.ts` (行 239-266) - -**現状**: - -```typescript -function mergeTaskAndAnswer( - task: Task, - userId: string, - answer: TaskAnswer | null | undefined, -): TaskResult; -``` - -**確認項目**: - -- ✅ `task` オブジェクトから `contest_id` が確実に取得できるか -- ✅ `createDefaultTaskResult()` で `contest_id` が保持されているか - -**現在の状態**: ✅ 問題なし(`task.contest_id` は含まれている) - -**重要**: この関数は以下の処理で統一ポイントとなるため、テスト対象として重要 - ---- - -### 3.5 サービス層:getTaskResultsOnlyResultExists() - -**ファイル**: `src/lib/services/task_results.ts` (行 151-183) - -**現状**: - -```typescript -export async function getTaskResultsOnlyResultExists( - userId: string, - with_map: boolean = false, -): Promise>; -``` - -**修正内容**: `mergeTaskAndAnswer()` を使用して重複排除 - -```typescript -export async function getTaskResultsOnlyResultExists( - userId: string, - with_map: boolean = false, -): Promise> { - const tasks = await getTasks(); - const answers = await answer_crud.getAnswers(userId); - - const tasksHasAnswer = tasks.filter((task) => answers.has(task.task_id)); - - // ⭐ mergeTaskAndAnswer を使用(重複排除) - const taskResults = tasksHasAnswer.map((task: Task) => { - const answer = answers.get(task.task_id); - return mergeTaskAndAnswer(userId, task, answer); - }); - - if (with_map) { - const taskResultsMap = new Map(); - taskResults.forEach((taskResult) => { - const key = createContestTaskPairKey(taskResult.contest_id, taskResult.task_id); - taskResultsMap.set(key, taskResult); - }); - return taskResultsMap; - } - - return taskResults; -} -``` - -**注意**: `with_map=true` の場合、キーを `"contestId:taskId"` に変更(workbooks との互換性) - ---- - -**修正内容**: - -1. キー形式を `"contestId:taskId"` に変更(`createContestTaskPairKey()` 使用) -2. 戻り値型を `TaskResultMapByContestTaskPair` に変更 -3. Task オブジェクトから `contest_id` を確実に取得 - -**影響を受ける関数**: - -- `mergeTaskAndAnswer()` - 直接呼び出し元 -- `getTaskResultsOnlyResultExists` の呼び出し元(workbooks ページサーバー) - ---- - -### 3.6 コンポーネント層:TaskTable.svelte - -**ファイル**: `src/lib/components/TaskTable.svelte` (行 126-152) - -**現状**: - -```typescript -let taskResultsMap = $derived(() => { - return taskResults.reduce((map: Map, taskResult: TaskResult) => { - if (!map.has(taskResult.task_id)) { - map.set(taskResult.task_id, taskResult); // ← キー: taskId のみ - } - return map; - }, new Map()); -}); -``` - -**問題点**: - -- キーが `taskId` のみで、同一タスク異なるコンテストで上書き -- 行 152 の参照で誤った TaskResult を取得する可能性 - -**修正内容**: - -```typescript -let taskResultsMap = $derived(() => { - return taskResults.reduce((map: Map, taskResult: TaskResult) => { - const key = createContestTaskPairKey(taskResult.contest_id, taskResult.task_id); - if (!map.has(key)) { - map.set(key, taskResult); - } - return map; - }, new Map()); -}); -``` - -**関連する参照箇所**: - -- 行 152: `taskIndicesMap().get(updatedTask.task_id)` も同様に修正 - ---- - -## IV. 間接的に影響を受ける範囲(中リスク) - -### 4.1 ページサーバー層 - -| ファイル | 現状 | 修正必要性 | 理由 | -| --------------------------------------------- | ------------------------------ | ----------- | -------------------------------------------- | -| `src/routes/problems/+page.server.ts` | `TaskResults[]` 配列返却 | ❌ 不要 | コンポーネント側で Map 変換(1回のみ) | -| `src/routes/problems/[slug]/+page.server.ts` | 単一タスク参照 | ❌ 不要 | taskId で一意に特定可能(単一問題ページ) | -| `src/routes/users/[username]/+page.server.ts` | `TaskResults[]` 配列返却 | ❌ 不要 | TaskListSorted で配列走査のみ | -| `src/routes/workbooks/[slug]/+page.server.ts` | `Map` 返却 | 🟡 将来対応 | 現在は workbook 内 taskId が一意(互換性可) | - -**判定根拠**: - -- `/problems` ページは **複数の異なる contestId:taskId を表示** するため、ページサーバー側では Map 化不要(配列のままコンポーネント側で変換で十分) - -### 4.2 workbooks ページ(互換性維持、将来対応予定) - -**ファイル**: `src/routes/workbooks/[slug]/+page.server.ts` (行 27) - -**現状**: - -```typescript -const taskResults: Map = await taskResultsCrud.getTaskResultsByTaskId( - workBook.workBookTasks, - loggedInUser?.id as string, -); -``` - -**ファイル**: `src/routes/workbooks/[slug]/+page.svelte` (行 44) - -```typescript -return taskResults?.get(taskId) as TaskResult; -``` - -**判定**: - -- ✅ 互換性維持可能 - Workbook 内では同一 taskId が複数 contestId を持たない -- 🟡 将来の拡張時に対応検討 - -**将来対応の検討項目**(メモ): - -```text -将来的に Workbook が複数 contestId:taskId ペアを持つようになった場合: -1. WorkbookTask に contest_id フィールド追加 -2. getTaskResultsByTaskId() の呼び出しで contestId も渡す -3. ページコンポーネント側で createContestTaskPairKey() を使用 -4. .get(taskId) → .get(createContestTaskPairKey(contestId, taskId)) -``` - ---- - -## V. 深層の影響範囲(変更不要の確認) - -### 5.1 コンポーネント層の詳細分析 - -| コンポーネント | 用途 | contest_id 利用 | 修正 | -| ---------------------------- | ------------------------ | ------------------------------------------- | ------- | -| **TaskList.svelte** | グレード別表示 | ✅ 既に複合キー使用(id属性) | ❌ 不要 | -| **TaskTableBodyCell.svelte** | セル内タスク表示 | ✅ `getTaskUrl(contest_id, task_id)` | ❌ 不要 | -| **TaskGradeList.svelte** | グレード別フィルタ | ⚠️ 不使用(grade でフィルタのみ) | ❌ 不要 | -| **TaskListSorted.svelte** | ユーザープロフィール表示 | ✅ 配列走査 + `addContestNameToTaskIndex()` | ❌ 不要 | -| **UpdatingModal.svelte** | 状態更新ダイアログ | 📋 確認必要 | ? | - -### 5.2 UpdatingModal.svelte の確認 - -**確認内容**: taskId をキーに何かしているか? - -→ **調査待機中**(現在の情報では影響なしと推測) - -### 5.3 ユーティリティ層の詳細分析 - -**`src/lib/utils/task.ts` の主要関数**: - -| 関数 | taskId キー依存 | 修正 | -| ------------------------------- | ---------------------------------- | ------- | -| `getTaskUrl(contestId, taskId)` | ❌ 両方を個別に受け取る | ❌ 不要 | -| `removeTaskIndexFromTitle()` | ❌ title 操作のみ | ❌ 不要 | -| `compareByContestIdAndTaskId()` | ❌ 比較関数(両方を使用) | ❌ 不要 | -| `getTaskTableHeaderName()` | ❌ contestType と task_table_index | ❌ 不要 | -| `calcGradeMode()` | ❌ grade のみ | ❌ 不要 | -| その他(色・ラベル関数) | ❌ grade 依存 | ❌ 不要 | - -→ **全て変更不要** ✅(taskId をキーに使っていない) - -**`src/lib/utils/contest.ts` の主要関数**: - -| 関数 | taskId キー依存 | 修正 | -| ----------------------------- | ------------------------------ | ------- | -| `classifyContest()` | ❌ contestId のみ | ❌ 不要 | -| `getContestNameLabel()` | ❌ contestId のみ | ❌ 不要 | -| `addContestNameToTaskIndex()` | ❌ contestId と taskTableIndex | ❌ 不要 | -| その他 | ❌ contestId 関連 | ❌ 不要 | - -→ **全て変更不要** ✅ - ---- - -## VI. テスト戦略 - -### 6.1 既存テストの影響範囲 - -**テスト対象**: `src/test/lib/utils/contest_task_pair.test.ts` - -```typescript -// ✅ このテストは変更不要(キー生成関数のテスト) -// 29個のテストケース全成功 -``` - -**影響を受けないテスト**: - -- ユーティリティテスト(task.ts, contest.ts など) -- Zod スキーマテスト -- Store テスト -- クライアントテスト - -### 6.2 修正に伴い新規作成が必要なテスト - -**📋 新規テスト: `src/test/lib/services/task_results.test.ts`** - -```typescript -describe('getTaskResultsByTaskId', () => { - // テストケース候補: - // 1. 戻り値が Map か確認 - // 2. キーが "contestId:taskId" 形式か確認 - // 3. 複数 contestId を持つ同一 taskId が衝突しないか確認 - // 4. 空配列入力時の挙動確認 - // 5. 存在しない taskId のスキップ確認 -}); - -describe('mergeTaskAndAnswer', () => { - // テストケース候補: - // 1. contest_id が正確に保持されているか確認 - // 2. アンサーなしの場合のデフォルト値確認 - // 3. アンサーありの場合のマージ確認 -}); -``` - -**新規 Fixture: src/test/lib/services/fixtures/task_results.ts**: - -- テスト用のモックデータやビルダー関数を集約 -- テストから上記のデータ・関数を分離して、見通しを良くするため - -**設計の原則**: - -- テストデータの中央集約で DRY 原則を維持 -- 複数テストで共有される Mock オブジェクトは fixtures に配置 -- ビルダー関数で柔軟なテストデータ生成を支援 - -### 6.3 目視テストチェックリスト - -#### ✅ `/problems` ページでの確認 - -| 項目 | チェック内容 | 優先度 | -| ----------------------- | -------------------------------------------------- | ------ | -| 複数コンテスト表示 | 同一タスクが複数コンテスト欄に表示されるか | 🔴 高 | -| タスク選択更新 | 一つ選択して更新すると、正しいセルのみ更新されるか | 🔴 高 | -| ブラウザ F12 コンソール | エラーなく実行されるか | 🔴 高 | -| ソート順序 | contestId 降順 → taskId 昇順で正しくソートされるか | 🟡 中 | - -#### ✅ `/workbooks/[slug]` ページでの確認 - -| 項目 | チェック内容 | 優先度 | -| ---------- | ---------------------------------- | ------ | -| 互換性確認 | 既存 workbook ページが動作するか | 🟡 中 | -| 状態更新 | タスク状態更新が正しく反映されるか | 🟡 中 | - -#### ✅ ユーザープロフィール (`/users/[username]`) - -| 項目 | チェック内容 | 優先度 | -| -------- | ------------------------------ | ------ | -| 配列表示 | タスク一覧が正しく表示されるか | 🟡 中 | - ---- - -## VII. リスク評価と対策 - -### 7.1 リスク高 - 直接キー依存 - -**対象**: `TaskTable.svelte` の `taskResultsMap` - -| リスク | 内容 | 対策 | -| ------ | ------------------------------------- | -------------------------------------- | -| 🔴 高 | 同一 taskId の複数 contestId で上書き | キー形式を `"contestId:taskId"` に統一 | -| 🔴 高 | 行 152 の参照で誤ったタスク取得 | `taskIndicesMap` も同時に修正 | -| 🟡 中 | 型安全性の欠落 | `ContestTaskPairKey` 型を明示的に使用 | - -### 7.2 リスク中 - サービス層 - -**対象**: `getTaskResultsByTaskId()` - -| リスク | 内容 | 対策 | -| ------ | -------------------------------- | -------------------------------------------------------- | -| 🟡 中 | workbooks ページで戻り値型が変更 | Map の値の取得箇所で `createContestTaskPairKey()` を使用 | -| 🟡 中 | `task.contest_id` の null 確認 | mergeTaskAndAnswer() で確認済み | - -### 7.3 リスク低 - その他 - -**判定**: ユーティリティ層、ページサーバー層は変更不要 → リスク低 - ---- - -## VIII. 修正順序と実装フロー(pre_plan.md) - -実装前計画ドキュメント: `docs/dev-notes/2025-10-26/impact-analysis/pre_plan.md` を参照 - -**推奨実装順序**: - -1. **Phase 1-A: サービス層** - - `mergeTaskAndAnswer` - `relateTasksAndAnswers` のメソッドの代わりに共通して利用 - - `getTaskResults()` - キー形式変更、戻り値型変更 - - `getTasksWithTagIds()` - getMergedTasksMap + mergeTaskAndAnswer 統合 - - `getTaskResultsByTaskId()` - キー形式変更、戻り値型変更 - - Delete `relateTasksAndAnswers()` - 関数を削除(getTaskResults に統合) - - テスト作成(新規) - -2. **Phase 1-B: コンポーネント層** - - `TaskTable.svelte` - `taskResultsMap` キー形式変更 - - `taskIndicesMap` も同時修正 - - 型インポート追加(`ContestTaskPairKey`, `createContestTaskPairKey`) - -3. **Phase 2: 互換性確認** - - `/problems` ページでの目視テスト - - `/workbooks` ページでの互換性確認 - - ユーザープロフィール確認 - -4. **Phase 3: オプション修正** - - `getTaskResultsOnlyResultExists()` - with_map=true 時のキー形式 - - workbooks への将来対応検討(メモ記載のみ) - ---- - -## IX. 影響を受けないモジュール - -**以下の項目は変更不要**: - -✅ ユーティリティ層全体 - -- `src/lib/utils/task.ts` -- `src/lib/utils/contest.ts` -- `src/lib/utils/account_transfer.ts`(タスク間の転送なので taskId ベース) - -✅ コンポーネント層(大部分) - -- `TaskList.svelte` -- `TaskTableBodyCell.svelte` -- `TaskGradeList.svelte` -- `TaskListSorted.svelte` - -✅ ページサーバー層(大部分) - -- `src/routes/problems/+page.server.ts` -- `src/routes/problems/[slug]/+page.server.ts` -- `src/routes/users/[username]/+page.server.ts` - -✅ テスト層 - -- 既存テスト全て(新規テスト作成が必要) - ---- - -## X. 補足事項と将来への備考 - -### 10.1 ContestTaskPair テーブルについて - -**現状**: seed.ts で 13 個のペアが投入済み - -**参考資料**: - -- Prisma スキーマ: `prisma/schema.prisma` の `model ContestTaskPair` -- データ: `prisma/contest_task_pairs.ts` - -### 10.2 キーヘルパー関数 - -**ファイル**: `src/lib/utils/contest_task_pair.ts` - -```typescript -export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey; -``` - -- ✅ 検証付き(空文字列チェック) -- ✅ テスト済み(29 ケース全成功) -- ✅ 形式: `"contestId:taskId"` - -### 10.3 Workbook の将来対応への考慮 - -**メモ**: workbooks ページは現在互換性を維持できるが、将来的に以下の要件が出た場合は拡張が必要: - -- 同一 workbook で同じ taskId が異なる contestId で出題される -- タスク表示に contestId による区別が必要 - -この場合のアクションアイテム: - -1. `WorkbookTask` テーブルに `contest_id` フィールド検討 -2. `getTaskResultsByTaskId()` の呼び出しで contestId を明示的に渡す -3. ページコンポーネント側で Map キー生成ロジック更新 - -### 10.4 デリミタ文字の注意 - -**重要**: キー形式が `"contestId:taskId"` なため、以下に注意: - -⚠️ **問題の例**: `contestId = "abc:123"` の場合 - -- 生成されるキー: `"abc:123:task_a"` -- `split(':')` で分割すると誤る可能性 - -**対策**: キーからの復元が必要な場合は、`split(':')` ではなく **最後のコロンで分割** するか、**キー生成時にバリデーション** を強化 - ---- - -## XI. 参考:既存文書との関連性 - -### 文献参照表 - -| タイトル | 目的 | 参照ポイント | -| ----------------------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------- | -| [Contest-Task Pair Mapping 実装計画](../../2025-09-23/contest-task-pair-mapping/plan.md) | DB 設計・型定義 | キー形式の決定、`TaskResultMapByContestTaskPair` 型 | -| [contest_task_pairs データ投入処理](../../2025-10-22/add-contest-task-pairs-to-seeds/plan.md) | Seed データ | 既投入済みの 13 ペア | -| [getMergedTasksMap リファクタリング教訓](../../2025-10-25/refactor-getMergedTasksMap/lesson.md) | ベストプラクティス | 関数型プログラミング、テスト設計 | - -### 推奨される読む順序 - -1. 本レポート(全体像把握) -2. Phase 2 詳細分析(現状確認) -3. Phase 1 調査結果(呼び出し元把握) -4. pre_plan.md(実装計画) -5. 関連文書(技術詳細) - ---- - -## XII. サマリーテーブル - -### 修正対象サマリー - -| レイヤ | ファイル | 関数/箇所 | 修正内容 | 優先度 | -| -------------- | ------------------ | ---------------------------------- | ------------------------------------------- | ------ | -| サービス | `task_results.ts` | `getTaskResults()` | getMergedTasksMap + mergeTaskAndAnswer 統合 | 🔴 1 | -| サービス | `task_results.ts` | `getTasksWithTagIds()` | getMergedTasksMap + mergeTaskAndAnswer 統合 | 🔴 1 | -| サービス | `task_results.ts` | `relateTasksAndAnswers()` | 削除(getTaskResults に統合) | 🔴 1 | -| サービス | `task_results.ts` | `getTaskResultsByTaskId()` | キー + 戻り値型 | 🟡 2 | -| サービス | `task_results.ts` | `getTaskResultsOnlyResultExists()` | mergeTaskAndAnswer 統合 | 🟡 2 | -| コンポーネント | `TaskTable.svelte` | `taskResultsMap` | キー形式 | 🔴 1 | -| コンポーネント | `TaskTable.svelte` | `taskIndicesMap` | キー形式 | 🔴 1 | -| テスト | `(new)` | `task_results.test.ts` | 新規作成 | 🔴 1 | - -### 非修正確認サマリー - -✅ ユーティリティ層(12 関数以上) -✅ ページサーバー層(大部分) -✅ その他コンポーネント(8 個以上) -✅ テスト層(既存テスト) - ---- - -## レポート終了 - ---- - -## 追加: ファイル統計 - -- **対象ファイル総数**: 約 220 ファイル -- **修正必須**: 2-4 ファイル(サービス + コンポーネント) -- **修正推奨**: 1 ファイル(テスト新規) -- **修正不要**: 200+ ファイル -- **修正予定**: 0 ファイル(互換性維持ため) - -**実装工数(概算)**: 2-3 時間(修正 + テスト + 目視確認) diff --git a/docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md b/docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md deleted file mode 100644 index 5175a8582..000000000 --- a/docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md +++ /dev/null @@ -1,115 +0,0 @@ -# Supabase に CSV からデータをインポートする方法 - -## 基本的な手順 - -### 1. CSV ファイルの準備 - -Supabase Dashboard からのインポートでは、以下のカラムを **必ず含める** 必要があります: - -- `id` - UUID(自動生成ではなく、CSV に含める必要がある) -- `createdAt` - タイムスタンプ -- `updatedAt` - タイムスタンプ -- その他のビジネスロジック用カラム - -**CSV ファイルの例(`contest_task_pairs.csv`):** - -```csv -id,contestId,taskId,taskTableIndex,createdAt,updatedAt -550e8400-e29b-41d4-a716-446655440000,tessoku-book,typical90_s,C18,2025-11-03T10:00:00.000Z,2025-11-03T10:00:00.000Z -6ba7b810-9dad-11d1-80b4-00c04fd430c8,tessoku-book,math_and_algorithm_ac,C09,2025-11-03T10:00:00.000Z,2025-11-03T10:00:00.000Z -7cb12b42-0b4a-11d2-91c5-00d04fd430c9,tessoku-book,abc007_3,B63,2025-11-03T10:00:00.000Z,2025-11-03T10:00:00.000Z -``` - -### 2. Python で UUID と タイムスタンプを生成 - -既存の CSV に `id`、`createdAt`、`updatedAt` を追加するスクリプト: - -```python -import uuid -import csv -from datetime import datetime - -# 現在時刻を ISO 8601 形式で取得 -now = datetime.utcnow().isoformat() + 'Z' - -# CSV を読み込み -with open('tessoku-book.csv', 'r') as f: - reader = csv.reader(f) - header = next(reader) - rows = list(reader) - -# ID とタイムスタンプを追加して新しい CSV を作成 -with open('tessoku-book-with-id.csv', 'w', newline='') as f: - writer = csv.writer(f) - writer.writerow(['id', 'createdAt', 'updatedAt'] + header) - - for row in rows: - writer.writerow([str(uuid.uuid4()), now, now] + row) - -print(f"✅ {len(rows)} 件のデータを処理しました") -``` - -### 3. Supabase Dashboard からインポート - -1. [Supabase ダッシュボード](https://supabase.com/dashboard)にログイン -2. 対象のプロジェクト → 「SQL Editor」または「Table Editor」を選択 -3. 対象テーブル(例:`contesttaskpair`)を選択 -4. 右上の「Import data」または「↓ Import」ボタンをクリック -5. 生成した CSV ファイル(`tessoku-book-with-id.csv`)を選択 -6. カラムのマッピングを確認 -7. 「Import」をクリック - -## トラブルシューティング - -### エラー: "null value in column "id" violates not-null constraint" - -**原因:** CSV に `id` カラムが含まれていない場合、Supabase が自動生成してくれません。 - -**解決方法:** 上記の Python スクリプトを使用して、UUID を生成した CSV を作成してください。 - -### エラー: カラム名が一致しない - -**原因:** CSV のカラム名とテーブルの属性名が異なる - -**対応:** - -- CSV のカラム名とテーブルの属性名が完全に一致していることを確認 -- インポート画面でカラムマッピングを手動で行う -- 大文字小文字は区別されるので注意 - -### CSV のカラム順序が異なる場合 - -**結果:** 問題ありません。Supabase はカラム名で識別するため、順序は関係ありません。 - -## 得られた教訓 - -### 1. 自動生成カラムについて - -Prisma スキーマで `@default(uuid())` や `@default(now())` が定義されていても、Supabase Dashboard の CSV インポート機能はこのデフォルト値を適用しません。 - -```prisma -model ContestTaskPair { - id String @id @default(uuid()) // ← デフォルト値が定義されていても - createdAt DateTime @default(now()) // ← CSV インポートには反映されない - updatedAt DateTime @updatedAt -} -``` - -**対策:** CSV ファイルに明示的にこれらのカラムを含める必要があります。 - -### 2. カラム名の一致は必須 - -- **必須:** CSV のカラム名 = テーブルの属性名 -- **不要:** カラムの順序 -- **補完:** インポート画面でマッピングできるが、あらかじめ一致させておくのが確実 - -### 3. UUID v4 の選択 - -デフォルトの `uuid()` (v4) はランダムなため、DB インデックスの効率が低下します。大規模なデータセットの場合は、`cuid()` の使用や UUID v6 の検討を推奨します。 - -## 参考資料 - -- [Supabase 公式 - Import data](https://supabase.com/docs/guides/database/import-data) -- [Prisma - @default 関数](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#default) -- [RFC 9562 - UUID Versions](https://tools.ietf.org/html/rfc9562) -- [UUID Library for Python - uuid4](https://docs.python.org/3/library/uuid.html#uuid.uuid4) diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index c5bb25a23..68fa3f2f7 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -70,7 +70,7 @@ protected setFilterCondition(): (taskResult: TaskResult) => boolean { 複数の異なる contest/task_id を1つのテーブルに表示。`classifyContest()` のみでフィルタ。セクション分割が必要な場合は `task_table_index` の先頭文字でサブフィルタ(Tessoku Book 参照)。 -`getMergedTasksMap()` が複数コンテスト由来の task_id を自動統合。テストデータは [prisma/contest_task_pairs.ts](../../prisma/contest_task_pairs.ts) 参照。 +`getMergedTasksMap()` が複数コンテスト由来の task_id を自動統合。テストデータは [prisma/contest_task_pairs.ts](../../prisma/contest_task_pairs.ts) 参照。ペアの追加手順と本番環境への投入は [how-to-add-contest-task-pairs.md](./how-to-add-contest-task-pairs.md) を参照。 ### パターン4: コンストラクタパラメータ型(ICPC / JAG / JOI) diff --git a/docs/guides/how-to-add-contest-task-pairs.md b/docs/guides/how-to-add-contest-task-pairs.md new file mode 100644 index 000000000..66d4acab9 --- /dev/null +++ b/docs/guides/how-to-add-contest-task-pairs.md @@ -0,0 +1,109 @@ +# ContestTaskPair 追加ガイド + +同じ問題(`task_id`)を別のコンテストにも表示するための `ContestTaskPair` を追加するときのリファレンス。テーブル(Provider)側の実装は [how-to-add-contest-table-provider.md](./how-to-add-contest-table-provider.md) のパターン3を参照。 + +--- + +## いつ必要か + +`Task.task_id` は一意で、`Task` 行が持つ `contest_id` は 1 つだけ(以下「本来のコンテスト」)。同じ問題が別のコンテストにも出題されているとき、その「別のコンテストでの登場」を 1 行ずつ `ContestTaskPair` に足す。 + +例: ABC042-125 と ARC058-103 の共有問題、ABS、鉄則本(`tessoku-book`)、アルゴリズムと数学(`math-and-algorithm`)、ABC-Like、`jsc2025advance-final`。 + +本来のコンテストと同じ `(contestId, taskId)` のペアは無視されるので、足す必要はない。 + +--- + +## 仕組み + +`getMergedTasksMap()`(`src/lib/services/tasks.ts`)が `Task` 行と `ContestTaskPair` を合わせ、`"contestId:taskId"`(`ContestTaskPairKey`)をキーにした Map を作る。ペア 1 件ごとに `Task` を複製し、次の 4 項目を書き換える。 + +| 項目 | 値 | +| ------------------ | --------------------------------------------------------------- | +| `contest_id` | `pair.contestId` | +| `contest_type` | `classifyContest(pair.contestId)` | +| `task_table_index` | `pair.taskTableIndex` | +| `title` | 元の `title` の最初の `task_table_index` を新しい記号に置換する | + +回答(`TaskAnswer`)は `(task_id, user_id)` 単位なので、どのコンテストの表から見ても同じ回答状況になる。 + +--- + +## 追加手順 + +1. **Task の存在**: `taskId` の問題が `prisma/tasks.ts`(本番は `task` テーブル)にあること。無いペアは seed が警告を出して飛ばし、`getMergedTasksMap()` も黙って捨てる +2. **分類**: `classifyContest(contestId)` が正しい `ContestType` を返すこと。`null` だとペアは黙って捨てられる +3. **データ**: `prisma/contest_task_pairs.ts` に行を足す。フィールド名は AtCoder Problems の `contest-problem.json` と同じ形で、DB の列名とは異なる +4. **テーブル**: 新しい表示先が必要なら Provider を追加する([how-to-add-contest-table-provider.md](./how-to-add-contest-table-provider.md)) +5. **検証用・本番環境**: Supabase に CSV で取り込む(次節) + +| `contest_task_pairs.ts` | DB 列(`contesttaskpair`) | 例 | +| ----------------------- | -------------------------- | -------------- | +| `contest_id` | `contestId` | `tessoku-book` | +| `problem_id` | `taskId` | `typical90_s` | +| `problem_index` | `taskTableIndex` | `C18` | + +--- + +## 検証用・本番環境への投入(Supabase CSV) + +管理画面からは取り込めない。Supabase Dashboard の Table Editor で `contesttaskpair` テーブルを開き、CSV を Import する。 + +- `id`・`createdAt`・`updatedAt` は手元で生成して CSV に必ず含める。`id`(`@default(uuid())`)と `updatedAt`(`@updatedAt`)は Prisma Client 側で埋める値で、DB に既定値が無い。`createdAt` には DB 既定値(`CURRENT_TIMESTAMP`)があるが、Supabase の CSV 取り込みでは自動で入らなかった +- 日時は `YYYY-MM-DD HH:MM:SS.mmm`(ミリ秒まで)の形式で入れる +- 列名は DB と同じ camelCase にする(`contestId`, `taskId`, `taskTableIndex`) +- 取り込んだペアは、サーバーのメモリキャッシュ(`getCachedMergedTasksMap`、TTL 1 時間)が切れるまで表示に反映されないことがある。DB への直接書き込みでは `invalidateTaskCaches()` が呼ばれないため + +`contest_id,problem_id,problem_index`(`contest_task_pairs.ts` と同じ形)の CSV から取り込み用の CSV を作る例(polars): + +```python +import uuid +from datetime import datetime, timedelta, timezone + +import polars as pl + +data_frame = pl.read_csv("contest_task_pairs.csv") + +# Add UUID column with unique values for each row. +# Add createdAt and updatedAt columns with current timestamp. +JST = timezone(timedelta(hours=9)) +current_time = datetime.now(tz=JST).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + +data_frame = data_frame.with_columns( + pl.Series("id", [str(uuid.uuid4()) for _ in range(len(data_frame))]), + pl.lit(current_time).alias("createdAt"), + pl.lit(current_time).alias("updatedAt"), +) +# Rename columns to camelCase. +data_frame = data_frame.rename( + { + "contest_id": "contestId", + "problem_id": "taskId", + "problem_index": "taskTableIndex", + } +) + +data_frame.write_csv("contest_task_pairs_for_import.csv") +``` + +--- + +## よくあるミス + +1. **title の形式** — title の書き換えは `title.replace(元の記号, 新しい記号)` で、最初に一致した箇所を置き換える。`C. Iroha's Obsession` や `019. Pick Two(★6)` のように title が「記号. 」で始まる AtCoder の問題を前提にしている。記号で始まらない title(AOJ など)では、名前の途中が置き換わる +2. **外部キーは無い** — `ContestTaskPair` から `Task` への外部キーは無い。存在しない `taskId` も CSV で入ってしまい、エラーにならずに表示されない +3. **キーを分解しない** — `ContestTaskPairKey` は `contestId` と `taskId` を `:` で連結しただけなので、`split(':')` で元に戻せる保証は無い。必要なときは `TaskResult` の `contest_id` / `task_id` を使う + +--- + +## 意思決定 + +- **`Task.task_id` の一意制約は維持**: 回答・問題集・投票など多くのテーブルが `task_id` を参照するため。同じ問題の別コンテストでの登場は、別テーブル(`ContestTaskPair`)で表す +- **キーは文字列 `"contestId:taskId"`**: JavaScript の `Map` は配列やオブジェクトを値で比較しないため、テンプレートリテラル型の文字列にした +- **問題集(workbooks)は `taskId` だけをキーにする**: 1 つの問題集に、同じ `taskId` が別のコンテストとして入ることは無いと判断した。入れたくなった場合は、`WorkBookTask` に `contest_id` を持たせ、`getTaskResultsByTaskId()` の Map キーと `/workbooks/[slug]` の参照を `createContestTaskPairKey()` に揃える +- **管理画面からの取り込みは作らない**: 追加は低頻度で、seed と Supabase の CSV で足りるため。`createContestTaskPair()` / `updateContestTaskPair()` は現在呼び出し元が無い +- **互換レイヤを挟まずに移行した**: 計画段階の `TaskMapAdapter` や Feature Flag は採用せず、`/problems` 系の Map キーを一度に `ContestTaskPairKey` へ切り替えた + +--- + +**最終更新**: 2026-10-26 From 664c6359bb33d602a14fa48ab8192e4cb9aeb566 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 26 Oct 2026 12:21:29 +0000 Subject: [PATCH 2/3] docs: add the out-of-app data import rule to the Prisma guide Co-Authored-By: Claude Opus 5.5 --- docs/guides/agent-rules/prisma-db.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/guides/agent-rules/prisma-db.md b/docs/guides/agent-rules/prisma-db.md index a405e9e82..f35cde2c5 100644 --- a/docs/guides/agent-rules/prisma-db.md +++ b/docs/guides/agent-rules/prisma-db.md @@ -98,6 +98,10 @@ user User @relation(fields: [userId], references: [id]) Add `CHECK` constraints via manual migration SQL. Document with inline `schema.prisma` comments (e.g., `/// CHECK: count >= 0`). Note: `prisma generate` (via `prisma-markdown`) overwrites `ERD.md`—always keep the constraint definition in `schema.prisma` as the source of truth. +## Out-of-App Data Imports + +Supabase Dashboard CSV imports and raw SQL bypass Prisma Client, so `@default(uuid())` and `@updatedAt` are not filled: include `id` and `updatedAt` explicitly. Include `createdAt` too — the Supabase CSV import left it empty despite its DB default. + ## Service Layer Error Handling Catch Prisma errors in service functions, return domain values: From 76eb9266417efbe54938ce936681578b44c673e5 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 26 Oct 2026 12:23:04 +0000 Subject: [PATCH 3/3] docs: clarify that no admin UI is planned for contest task pairs Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/how-to-add-contest-task-pairs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/how-to-add-contest-task-pairs.md b/docs/guides/how-to-add-contest-task-pairs.md index 66d4acab9..1d3ee5c9b 100644 --- a/docs/guides/how-to-add-contest-task-pairs.md +++ b/docs/guides/how-to-add-contest-task-pairs.md @@ -101,7 +101,7 @@ data_frame.write_csv("contest_task_pairs_for_import.csv") - **`Task.task_id` の一意制約は維持**: 回答・問題集・投票など多くのテーブルが `task_id` を参照するため。同じ問題の別コンテストでの登場は、別テーブル(`ContestTaskPair`)で表す - **キーは文字列 `"contestId:taskId"`**: JavaScript の `Map` は配列やオブジェクトを値で比較しないため、テンプレートリテラル型の文字列にした - **問題集(workbooks)は `taskId` だけをキーにする**: 1 つの問題集に、同じ `taskId` が別のコンテストとして入ることは無いと判断した。入れたくなった場合は、`WorkBookTask` に `contest_id` を持たせ、`getTaskResultsByTaskId()` の Map キーと `/workbooks/[slug]` の参照を `createContestTaskPairKey()` に揃える -- **管理画面からの取り込みは作らない**: 追加は低頻度で、seed と Supabase の CSV で足りるため。`createContestTaskPair()` / `updateContestTaskPair()` は現在呼び出し元が無い +- **管理画面用のUIは作らない**: 追加は低頻度で、seed と Supabase の CSV で足りるため。`createContestTaskPair()` / `updateContestTaskPair()` は現在呼び出し元が無い - **互換レイヤを挟まずに移行した**: 計画段階の `TaskMapAdapter` や Feature Flag は採用せず、`/problems` 系の Map キーを一度に `ContestTaskPairKey` へ切り替えた ---