Skip to content

feat: #110 クエスト詳細画面を実 API に接続する#111

Merged
Inlet-back merged 1 commit intodevelopfrom
feature/issue-110-quest-detail-page
Feb 21, 2026
Merged

feat: #110 クエスト詳細画面を実 API に接続する#111
Inlet-back merged 1 commit intodevelopfrom
feature/issue-110-quest-detail-page

Conversation

@Reeeid
Copy link
Contributor

@Reeeid Reeeid commented Feb 21, 2026

Issue: #110

実装の概要

クエスト詳細画面を実 API に接続しました。mock データから FastAPI バックエンドの Quest API(ADR 013)への切り替えを行い、Markdown 形式で保存されたクエスト説明文を
eact-markdown\ +
emark-gfm\ でレンダリングできるようにしました。また、クエストの進捗管理(not_started in_progress completed)をフロントエンドで制御するステートマシンを実装しました。

🔧 技術的な意思決定とトレードオフ (最重要)

採用したアプローチ

  • 手法: バックエンドの \GET /api/v1/quest?category={}\ と \GET /api/v1/quest/{id}\ を直接 fetch し、
    eact-markdown\ で description を描画。進捗は \POST .../start\ / \complete\ で管理。
  • メリット: ADR 013 のエンドポイント設計に忠実に従い、認証不要の一覧取得と JWT 必須の進捗管理を明確に分離できている。
    eact-markdown\ +
    emark-gfm\ は ADR 012(Markdown 保存)を活かしてリッチな表示が可能。
  • デメリット/リスク: 未ログイン状態で「開始」ボタンを押すと 401 エラーを UI 上で返すが、ログイン誘導 UI(リダイレクト等)は未実装。エラーメッセージで対応している。

却下したアプローチ(代替案)

  • 手法: \dangerouslySetInnerHTML\ で description を HTML として直接挿入
  • 却下理由: XSS リスクが高く、セキュリティ SKILL の禁止事項に抵触するため

🧪 テスト戦略と範囲

追加したテストケース

  • 正常系: \ sc --noEmit\ pass(型エラーなし)
  • 正常系: dev server 起動確認
  • 正常系: シードデータ 10 件投入 API レスポンス確認(id=7〜16)
  • 異常系: NaN ガード(exerciseId が数値でない場合は「クエストが見つかりません」表示)
  • テストしていないこと: E2E ブラウザテスト(ログイン 開始 完了フロー)、API エラー時のリトライロジック、モバイルレイアウト確認

セキュリティに関する自己評価

  • 機密情報のハードコードはないか(API ベース URL は環境変数 \NEXT_PUBLIC_API_BASE_URL\ 経由)
  • 入力値の検証(バリデーション)は行っているか(questId の NaN ガード実装済み)
  • 既知の脆弱性パターンへの対策は考慮したか(Markdown は
    eact-markdown\ でサニタイズ済み、\dangerouslySetInnerHTML\ 不使用)

レビュワー(人間)への申し送り事項

  • \mapDifficultyToLevel()\ は difficulty 0-9 を beginner/intermediate/advanced/expert に変換。現在のシードデータは diff 1-6 の範囲なので expert タブには表示されません。
    0-2→beginner, 3-5→intermediate, 6-7→advanced, 8-9→expert の4分割はフロントエンド独自の解釈、存在するコンポーネントに合わせた
  • \mobile\、
    etwork\ カテゴリは \QuestCategory\ Enum に未定義のため、該当カテゴリページは空配列を返します(ExerciseMenu の表示項目との乖離あり別 Issue で対応予定)。
  • バックエンド URL は \NEXT_PUBLIC_API_BASE_URL\ 環境変数で制御。
2bbb2b08cdb00c670b185ee210327e12 9ab022bf97ccfb481c099e17ffe96a89

?????はpowershellの仕様でそうなってるだけなので気にしないでいただいて
Closes #110

- 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 追加
Copilot AI review requested due to automatic review settings February 21, 2026 20:13
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 で返す
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMyQuestProgress() が !res.ok の場合に無条件で null を返しており、コメントにある「未ログイン (401) など」以外(例: 500/502)も握りつぶしてしまいます。サーバ障害時に UI が「未開始」と誤認するので、res.status を見て 401/403 のみ null、それ以外はエラーとして throw する形にしてください。

Suggested change
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}`);
}

Copilot uses AI. Check for mistakes.
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}`);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getQuestDetail() のエラーメッセージが !res.ok を一律で「演習が見つかりません」にしており、500 等でも 404 相当の文言になってしまいます。res.status が 404 のときのみ「見つかりません」にし、それ以外は「取得に失敗しました」系に分けるか、API の detail を読んでメッセージに反映してください。

Suggested change
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}`);
}

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +28
const DIFFICULTY_COLOR: Record<string, string> = {
beginner: 'bg-[#FCD34D]',
intermediate: 'bg-[#FB923C]',
advanced: 'bg-[#F87171]',
expert: 'bg-[#C084FC]',
};
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DIFFICULTY_COLOR が Record<string, string> になっているため、キーの不足や誤字を型で検出できません。DifficultyLevel を使って Record<DifficultyLevel, string> にすると、難易度追加/変更時の破壊をコンパイル時に検出できます。

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +11
function getApiBaseUrl(): string {
return process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 説明では環境変数が NEXT_PUBLIC_API_BASE_URL となっていますが、コードベース(auth/skillTree など含む)では NEXT_PUBLIC_API_URL に統一されています。説明と実装のどちらかに合わせて、環境変数名の表記を統一してください。

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 9 comments.

Files not reviewed (1)
  • frontend/package-lock.json: Language not supported

const err = await res.json().catch(() => ({})) as { detail?: string };
throw new Error(err.detail ?? `クエスト開始に失敗しました: ${res.status}`);
}
return res.json() as Promise<QuestProgressResponse>;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

型アサーションが不要です。fetch の返り値 res.json() は既に Promise を返すため、as Promise<QuestProgressResponse> の型アサーションは不要です。単に return res.json(); とするだけで TypeScript は正しく推論します。

Copilot uses AI. Check for mistakes.
const err = await res.json().catch(() => ({})) as { detail?: string };
throw new Error(err.detail ?? `クエスト完了に失敗しました: ${res.status}`);
}
return res.json() as Promise<QuestProgressResponse>;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

型アサーションが不要です。fetch の返り値 res.json() は既に Promise を返すため、as Promise<QuestProgressResponse> の型アサーションは不要です。単に return res.json(); とするだけで TypeScript は正しく推論します。

Copilot uses AI. Check for mistakes.
<div className="mb-6 flex items-center gap-4">
{status === 'not_started' && (
<button
onClick={() => { void handleStart(); }}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise のエラーハンドリングが void に変換されています。void handleStart() は Promise の拒否を無視するため、エラーが適切にキャッチされない可能性があります。エラーは既に handleStart 内で setError によって処理されているので問題ありませんが、より明示的に handleStart().catch(() => {}) とするか、React 19 の非同期トランジション機能の使用を検討してください。

Suggested change
onClick={() => { void handleStart(); }}
onClick={() => {
handleStart().catch(() => {
// エラーは handleStart 内で setError により処理済み
});
}}

Copilot uses AI. Check for mistakes.
<>
<span className="px-4 py-2 bg-[#FCD34D] border-2 border-black font-bold text-sm">▶ 進行中</span>
<button
onClick={() => { void handleComplete(); }}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise のエラーハンドリングが void に変換されています。void handleComplete() は Promise の拒否を無視するため、エラーが適切にキャッチされない可能性があります。エラーは既に handleComplete 内で setError によって処理されているので問題ありませんが、より明示的に handleComplete().catch(() => {}) とするか、React 19 の非同期トランジション機能の使用を検討してください。

Copilot uses AI. Check for mistakes.
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>;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

型アサーションが不要です。fetch の返り値 res.json() は既に Promise を返すため、as Promise<QuestDetail> の型アサーションは不要です。単に return res.json(); とするだけで TypeScript は正しく推論します。他の類似関数(startQuest, completeQuest)でも同様の問題があります。

Copilot uses AI. Check for mistakes.
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`クエスト一覧の取得に失敗しました: ${res.status}`);

const data: QuestSummary[] = await res.json() as QuestSummary[];
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

型アサーション as QuestSummary[] が不安全です。バックエンドから予期しないデータが返ってきた場合、実行時エラーが発生する可能性があります。実運用環境では、zod などのバリデーションライブラリで実行時型チェックを行うことを推奨します。

Copilot uses AI. Check for mistakes.
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[];
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

型アサーション as QuestProgressResponse[] が不安全です。バックエンドから予期しないデータが返ってきた場合、実行時エラーが発生する可能性があります。実運用環境では、zod などのバリデーションライブラリで実行時型チェックを行うことを推奨します。

Copilot uses AI. Check for mistakes.
title: q.title,
category: q.category,
difficulty: mapDifficultyToLevel(q.difficulty),
status: 'not-started' as const,
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ステータス値の型不整合があります。フロントエンドの ExerciseStatus 型は 'not-started' | 'in-progress' | 'completed'(ハイフン区切り)ですが、バックエンドの QuestStatus は 'not_started' | 'in_progress' | 'completed'(アンダースコア区切り)です。現在 'not-started' as const でハードコードしていますが、バックエンドの進捗データと整合性が取れません。型定義を統一するか、変換関数を実装してください。

Copilot uses AI. Check for mistakes.
Comment on lines +157 to 160
{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>
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

エラーメッセージの表示位置が不適切です。エラーは進捗アクションボタンの上(line 158)に表示されますが、questがnullの場合のエラー(line 103)は別の場所に表示されます。ユーザー体験を統一するため、エラーメッセージの表示位置を一貫させることを推奨します。

Copilot uses AI. Check for mistakes.
@Inlet-back Inlet-back merged commit bd7ba88 into develop Feb 21, 2026
12 checks passed
@Inlet-back Inlet-back requested a review from Copilot February 21, 2026 21:10
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

環境変数名が他のファイルと一貫していません。このコードベースでは NEXT_PUBLIC_API_URL を使用していますが、PR 説明では NEXT_PUBLIC_API_BASE_URL が言及されています。他のファイル(frontend/src/lib/api/auth.tsfrontend/src/features/dashboard/api/rankApi.ts など)では NEXT_PUBLIC_API_URL を使用しているため、この実装は正しいです。ただし、PR 説明の記載を修正することを推奨します。

Copilot uses AI. Check for mistakes.
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`クエスト一覧の取得に失敗しました: ${res.status}`);

const data: QuestSummary[] = await res.json() as QuestSummary[];
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

型アサーション as QuestSummary[] を使用していますが、res.json() の戻り値を直接アサーションするよりも、レスポンスのバリデーションを行うことを推奨します。ただし、これは既存のコードベース(frontend/src/lib/api/auth.ts など)のパターンと一貫しているため、現状の実装は問題ありません。将来的には zod などのランタイムバリデーションライブラリの導入を検討してください。

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: クエスト詳細画面を実 API に接続する

3 participants