Conversation
- types/quest.ts: QuestSummary/QuestDetail 型定義と mapDifficultyToLevel() 追加
- api/questApi.ts: getQuestsByCategory/getQuestDetail/startQuest/completeQuest 実装
- QuestMarkdownContent.tsx: react-markdown + remark-gfm で Markdown 描画コンポーネント追加
- ExerciseList.tsx: mock API -> 実 API (GET /api/v1/quest?category={}) に切り替え
- [category]/[exerciseId]/page.tsx: 全面刷新 (not_started->in_progress->completed ステートマシン)
- package.json: react-markdown@^10.1.0 / remark-gfm@^4.0.1 追加
There was a problem hiding this comment.
Pull request overview
クエスト詳細画面をモックから FastAPI バックエンドの Quest API(ADR 013)へ接続し、Markdown 説明文のレンダリングと開始/完了による進捗操作をフロント側に追加するPRです。
Changes:
- Quest API 用の型定義と API クライアント(一覧/詳細/進捗取得/開始/完了)を追加
- クエスト詳細ページを実 API ベースに刷新し、進捗状態に応じたアクションボタンを実装
- Markdown(react-markdown + remark-gfm)表示コンポーネントを追加し、依存パッケージを追加
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/features/exercise/types/quest.ts | Quest API(一覧/詳細/進捗)の型定義と difficulty 変換を追加 |
| frontend/src/features/exercise/api/questApi.ts | Quest API クライアント(GET一覧/GET詳細/GET進捗/POST開始/POST完了)を実装 |
| frontend/src/features/exercise/components/QuestMarkdownContent.tsx | Markdown を GFM 対応でレンダリングする UI コンポーネントを追加 |
| frontend/src/features/exercise/components/ExerciseList.tsx | 演習一覧取得を mock → 実 Quest API に切り替え |
| frontend/src/app/exercises/[category]/[exerciseId]/page.tsx | 詳細ページを Quest API 連携+進捗アクション+Markdown 表示に刷新 |
| frontend/package.json | react-markdown / remark-gfm を依存関係に追加 |
| frontend/package-lock.json | 依存追加に伴うロックファイル更新 |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
| export async function getMyQuestProgress(questId: number): Promise<QuestProgressResponse | null> { | ||
| const url = `${getApiBaseUrl()}/api/v1/users/me/quest-progress`; | ||
| const res = await fetch(url, { cache: 'no-store', credentials: 'include' }); | ||
| if (!res.ok) return null; // 未ログイン (401) などは null で返す |
There was a problem hiding this comment.
getMyQuestProgress() が !res.ok の場合に無条件で null を返しており、コメントにある「未ログイン (401) など」以外(例: 500/502)も握りつぶしてしまいます。サーバ障害時に UI が「未開始」と誤認するので、res.status を見て 401/403 のみ null、それ以外はエラーとして throw する形にしてください。
| if (!res.ok) return null; // 未ログイン (401) などは null で返す | |
| if (!res.ok) { | |
| // 未ログイン (401/403) の場合は進捗なしとして扱う | |
| if (res.status === 401 || res.status === 403) { | |
| return null; | |
| } | |
| // それ以外のステータスはエラーとして扱う | |
| const err = await res.json().catch(() => ({})) as { detail?: string }; | |
| throw new Error(err.detail ?? `クエスト進捗の取得に失敗しました: ${res.status}`); | |
| } |
| export async function getQuestDetail(questId: number): Promise<QuestDetail> { | ||
| const url = `${getApiBaseUrl()}/api/v1/quest/${questId}`; | ||
| const res = await fetch(url, { cache: 'no-store' }); | ||
| if (!res.ok) throw new Error(`演習が見つかりません: ${res.status}`); |
There was a problem hiding this comment.
getQuestDetail() のエラーメッセージが !res.ok を一律で「演習が見つかりません」にしており、500 等でも 404 相当の文言になってしまいます。res.status が 404 のときのみ「見つかりません」にし、それ以外は「取得に失敗しました」系に分けるか、API の detail を読んでメッセージに反映してください。
| if (!res.ok) throw new Error(`演習が見つかりません: ${res.status}`); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})) as { detail?: string }; | |
| if (res.status === 404) { | |
| throw new Error(err.detail ?? `演習が見つかりませんでした: ${res.status}`); | |
| } | |
| throw new Error(err.detail ?? `演習の取得に失敗しました: ${res.status}`); | |
| } |
| const DIFFICULTY_COLOR: Record<string, string> = { | ||
| beginner: 'bg-[#FCD34D]', | ||
| intermediate: 'bg-[#FB923C]', | ||
| advanced: 'bg-[#F87171]', | ||
| expert: 'bg-[#C084FC]', | ||
| }; |
There was a problem hiding this comment.
DIFFICULTY_COLOR が Record<string, string> になっているため、キーの不足や誤字を型で検出できません。DifficultyLevel を使って Record<DifficultyLevel, string> にすると、難易度追加/変更時の破壊をコンパイル時に検出できます。
| function getApiBaseUrl(): string { | ||
| return process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||
| } |
There was a problem hiding this comment.
PR 説明では環境変数が NEXT_PUBLIC_API_BASE_URL となっていますが、コードベース(auth/skillTree など含む)では NEXT_PUBLIC_API_URL に統一されています。説明と実装のどちらかに合わせて、環境変数名の表記を統一してください。
| const err = await res.json().catch(() => ({})) as { detail?: string }; | ||
| throw new Error(err.detail ?? `クエスト開始に失敗しました: ${res.status}`); | ||
| } | ||
| return res.json() as Promise<QuestProgressResponse>; |
There was a problem hiding this comment.
型アサーションが不要です。fetch の返り値 res.json() は既に Promise を返すため、as Promise<QuestProgressResponse> の型アサーションは不要です。単に return res.json(); とするだけで TypeScript は正しく推論します。
| const err = await res.json().catch(() => ({})) as { detail?: string }; | ||
| throw new Error(err.detail ?? `クエスト完了に失敗しました: ${res.status}`); | ||
| } | ||
| return res.json() as Promise<QuestProgressResponse>; |
There was a problem hiding this comment.
型アサーションが不要です。fetch の返り値 res.json() は既に Promise を返すため、as Promise<QuestProgressResponse> の型アサーションは不要です。単に return res.json(); とするだけで TypeScript は正しく推論します。
| <div className="mb-6 flex items-center gap-4"> | ||
| {status === 'not_started' && ( | ||
| <button | ||
| onClick={() => { void handleStart(); }} |
There was a problem hiding this comment.
Promise のエラーハンドリングが void に変換されています。void handleStart() は Promise の拒否を無視するため、エラーが適切にキャッチされない可能性があります。エラーは既に handleStart 内で setError によって処理されているので問題ありませんが、より明示的に handleStart().catch(() => {}) とするか、React 19 の非同期トランジション機能の使用を検討してください。
| onClick={() => { void handleStart(); }} | |
| onClick={() => { | |
| handleStart().catch(() => { | |
| // エラーは handleStart 内で setError により処理済み | |
| }); | |
| }} |
| <> | ||
| <span className="px-4 py-2 bg-[#FCD34D] border-2 border-black font-bold text-sm">▶ 進行中</span> | ||
| <button | ||
| onClick={() => { void handleComplete(); }} |
There was a problem hiding this comment.
Promise のエラーハンドリングが void に変換されています。void handleComplete() は Promise の拒否を無視するため、エラーが適切にキャッチされない可能性があります。エラーは既に handleComplete 内で setError によって処理されているので問題ありませんが、より明示的に handleComplete().catch(() => {}) とするか、React 19 の非同期トランジション機能の使用を検討してください。
| const url = `${getApiBaseUrl()}/api/v1/quest/${questId}`; | ||
| const res = await fetch(url, { cache: 'no-store' }); | ||
| if (!res.ok) throw new Error(`演習が見つかりません: ${res.status}`); | ||
| return res.json() as Promise<QuestDetail>; |
There was a problem hiding this comment.
型アサーションが不要です。fetch の返り値 res.json() は既に Promise を返すため、as Promise<QuestDetail> の型アサーションは不要です。単に return res.json(); とするだけで TypeScript は正しく推論します。他の類似関数(startQuest, completeQuest)でも同様の問題があります。
| const res = await fetch(url, { cache: 'no-store' }); | ||
| if (!res.ok) throw new Error(`クエスト一覧の取得に失敗しました: ${res.status}`); | ||
|
|
||
| const data: QuestSummary[] = await res.json() as QuestSummary[]; |
There was a problem hiding this comment.
型アサーション as QuestSummary[] が不安全です。バックエンドから予期しないデータが返ってきた場合、実行時エラーが発生する可能性があります。実運用環境では、zod などのバリデーションライブラリで実行時型チェックを行うことを推奨します。
| const url = `${getApiBaseUrl()}/api/v1/users/me/quest-progress`; | ||
| const res = await fetch(url, { cache: 'no-store', credentials: 'include' }); | ||
| if (!res.ok) return null; // 未ログイン (401) などは null で返す | ||
| const list: QuestProgressResponse[] = await res.json() as QuestProgressResponse[]; |
There was a problem hiding this comment.
型アサーション as QuestProgressResponse[] が不安全です。バックエンドから予期しないデータが返ってきた場合、実行時エラーが発生する可能性があります。実運用環境では、zod などのバリデーションライブラリで実行時型チェックを行うことを推奨します。
| title: q.title, | ||
| category: q.category, | ||
| difficulty: mapDifficultyToLevel(q.difficulty), | ||
| status: 'not-started' as const, |
There was a problem hiding this comment.
ステータス値の型不整合があります。フロントエンドの ExerciseStatus 型は 'not-started' | 'in-progress' | 'completed'(ハイフン区切り)ですが、バックエンドの QuestStatus は 'not_started' | 'in_progress' | 'completed'(アンダースコア区切り)です。現在 'not-started' as const でハードコードしていますが、バックエンドの進捗データと整合性が取れません。型定義を統一するか、変換関数を実装してください。
| {error && ( | ||
| <div className="mb-4 p-4 bg-red-100 border-4 border-red-500 text-red-700 font-bold" style={{ boxShadow: '4px 4px 0 #b91c1c' }}> | ||
| ⚠ {error} | ||
| </div> |
There was a problem hiding this comment.
エラーメッセージの表示位置が不適切です。エラーは進捗アクションボタンの上(line 158)に表示されますが、questがnullの場合のエラー(line 103)は別の場所に表示されます。ユーザー体験を統一するため、エラーメッセージの表示位置を一貫させることを推奨します。
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)
frontend/src/features/exercise/components/ExerciseList.tsx:27
- エラーハンドリングが不十分です。
getQuestsByCategoryの呼び出しが失敗した場合、エラーはコンソールにログされるだけで、ユーザーにはエラー状態が表示されません。同じファイル内の詳細ページ(page.tsx)では error state を使ってユーザーにエラーメッセージを表示しています。同様のパターンを採用することを推奨します。例:const [error, setError] = useState<string | null>(null)を追加し、エラー時に UI にメッセージを表示する。
} catch (error) {
console.error('Failed to fetch exercises:', error);
} finally {
| import { mapDifficultyToLevel, type QuestDetail, type QuestProgressResponse, type QuestSummary } from '../types/quest'; | ||
|
|
||
| function getApiBaseUrl(): string { | ||
| return process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; |
There was a problem hiding this comment.
環境変数名が他のファイルと一貫していません。このコードベースでは NEXT_PUBLIC_API_URL を使用していますが、PR 説明では NEXT_PUBLIC_API_BASE_URL が言及されています。他のファイル(frontend/src/lib/api/auth.ts、frontend/src/features/dashboard/api/rankApi.ts など)では NEXT_PUBLIC_API_URL を使用しているため、この実装は正しいです。ただし、PR 説明の記載を修正することを推奨します。
| const res = await fetch(url, { cache: 'no-store' }); | ||
| if (!res.ok) throw new Error(`クエスト一覧の取得に失敗しました: ${res.status}`); | ||
|
|
||
| const data: QuestSummary[] = await res.json() as QuestSummary[]; |
There was a problem hiding this comment.
型アサーション as QuestSummary[] を使用していますが、res.json() の戻り値を直接アサーションするよりも、レスポンスのバリデーションを行うことを推奨します。ただし、これは既存のコードベース(frontend/src/lib/api/auth.ts など)のパターンと一貫しているため、現状の実装は問題ありません。将来的には zod などのランタイムバリデーションライブラリの導入を検討してください。
Issue: #110
実装の概要
クエスト詳細画面を実 API に接続しました。mock データから FastAPI バックエンドの Quest API(ADR 013)への切り替えを行い、Markdown 形式で保存されたクエスト説明文を
eact-markdown\ +
emark-gfm\ でレンダリングできるようにしました。また、クエストの進捗管理(not_started in_progress completed)をフロントエンドで制御するステートマシンを実装しました。
🔧 技術的な意思決定とトレードオフ (最重要)
採用したアプローチ
eact-markdown\ で description を描画。進捗は \POST .../start\ / \complete\ で管理。
eact-markdown\ +
emark-gfm\ は ADR 012(Markdown 保存)を活かしてリッチな表示が可能。
却下したアプローチ(代替案)
🧪 テスト戦略と範囲
追加したテストケース
セキュリティに関する自己評価
eact-markdown\ でサニタイズ済み、\dangerouslySetInnerHTML\ 不使用)
レビュワー(人間)への申し送り事項
0-2→beginner, 3-5→intermediate, 6-7→advanced, 8-9→expert の4分割はフロントエンド独自の解釈、存在するコンポーネントに合わせた
etwork\ カテゴリは \QuestCategory\ Enum に未定義のため、該当カテゴリページは空配列を返します(ExerciseMenu の表示項目との乖離あり別 Issue で対応予定)。
?????はpowershellの仕様でそうなってるだけなので気にしないでいただいて
Closes #110