feat: Feature-based Architecture採用によるダッシュボード機能の実装#34
Conversation
## 実装内容 - Feature-based Architectureに基づく`src/features/dashboard`ディレクトリ構造を構築 - ダッシュボード機能専用の以下のコンポーネントを実装: - StatusCard.tsx: ユーザーレベル、経験値、進捗表示 - BadgeList.tsx: 獲得済み・未獲得バッジの表示 - SkillRoadmap.tsx: 階層的なスキルツリーの表示 - DashboardContainer.tsx: データフェッチと全体管理 ## 設計理由 - Feature-basedの採用により、ドメインごとの機能隔離が実現 - `app/(dashboard)`ページレイヤーではロジックを持たず、`features/dashboard`に責務を集約 - future-proofな構成で、今後の機能追加もスケーラブルに対応可能 ## セキュリティ考慮 - モックデータの利用(バックエンド実装時に切り替え予定) - 型安全性をTypeScriptで確保 ## タイプセーフティ - 全コンポーネントに厳密な型定義を適用 - ダークモード対応(Tailwind CSS `dark:` class使用)
There was a problem hiding this comment.
Pull request overview
ダッシュボード機能を frontend/src/features/dashboard/ 配下に隔離し、App Router の (dashboard) ルートから表示できるようにするPRです(Feature-based Architecture の導入例としての実装)。
Changes:
features/dashboardに型定義・モックAPI・UIコンポーネント・統合コンテナを新規追加app/(dashboard)にページ/レイアウトを追加してダッシュボードを表示- ルート
app/layout.tsxのメタデータと言語設定を更新
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/features/dashboard/types/index.ts | ダッシュボードドメイン型を定義 |
| frontend/src/features/dashboard/api/mock.ts | ダッシュボード用モックデータ取得関数を追加 |
| frontend/src/features/dashboard/components/StatusCard.tsx | ユーザーステータス(ランク/EXP等)の表示を追加 |
| frontend/src/features/dashboard/components/BadgeList.tsx | 獲得/未獲得バッジ表示を追加 |
| frontend/src/features/dashboard/components/SkillRoadmap.tsx | スキルツリーの再帰表示を追加 |
| frontend/src/features/dashboard/components/DashboardContainer.tsx | データフェッチ/状態管理/各コンポーネント統合を追加 |
| frontend/src/app/layout.tsx | アプリ全体のmetadataと言語属性を変更 |
| frontend/src/app/(dashboard)/page.tsx | ダッシュボードページを追加 |
| frontend/src/app/(dashboard)/layout.tsx | ダッシュボード用レイアウト/metadata を追加 |
| {/* 子スキル */} | ||
| {childSkills.length > 0 && ( | ||
| <div className="mt-4 space-y-4"> | ||
| {childSkills.map((childSkill) => ( | ||
| <SkillNode | ||
| key={childSkill.id} | ||
| skill={childSkill} | ||
| allSkills={allSkills} | ||
| depth={depth + 1} | ||
| /> | ||
| ))} | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
子要素の描画がデータに完全依存した再帰になっているため、prerequisites に循環参照が含まれると無限再帰(スタックオーバーフロー)になります。visited セットでの循環検知や最大深度制限などのガードを入れてください。
| const loadDashboard = async () => { | ||
| try { | ||
| setLoading(true); | ||
| const data = await fetchUserDashboard(userId); | ||
| setUserStatus(data); | ||
| setError(null); | ||
| } catch (err) { | ||
| setError( | ||
| err instanceof Error ? err.message : 'ダッシュボードの読み込みに失敗しました' | ||
| ); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| loadDashboard(); |
There was a problem hiding this comment.
useEffect 内の非同期処理がコンポーネントのアンマウント後も setState し得るため、Reactの警告や不要な更新の原因になります。クリーンアップでキャンセルフラグ(または AbortController)を扱い、アンマウント後は state 更新しないようにしてください。
| const loadDashboard = async () => { | |
| try { | |
| setLoading(true); | |
| const data = await fetchUserDashboard(userId); | |
| setUserStatus(data); | |
| setError(null); | |
| } catch (err) { | |
| setError( | |
| err instanceof Error ? err.message : 'ダッシュボードの読み込みに失敗しました' | |
| ); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| loadDashboard(); | |
| let isCancelled = false; | |
| const loadDashboard = async () => { | |
| try { | |
| setLoading(true); | |
| const data = await fetchUserDashboard(userId); | |
| if (isCancelled) { | |
| return; | |
| } | |
| setUserStatus(data); | |
| setError(null); | |
| } catch (err) { | |
| if (isCancelled) { | |
| return; | |
| } | |
| setError( | |
| err instanceof Error ? err.message : 'ダッシュボードの読み込みに失敗しました' | |
| ); | |
| } finally { | |
| if (!isCancelled) { | |
| setLoading(false); | |
| } | |
| } | |
| }; | |
| loadDashboard(); | |
| return () => { | |
| isCancelled = true; | |
| }; |
| export function DashboardContainer({ userId = 'default-user' }: DashboardContainerProps) { | ||
| const [userStatus, setUserStatus] = useState<UserStatus | null>(null); | ||
| const [loading, setLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| const loadDashboard = async () => { |
There was a problem hiding this comment.
userId のデフォルト値が固定文字列(default-user)だと、呼び出し側が渡し忘れた場合に意図せず別ユーザーのダッシュボードとして扱われます。必須にするか、未指定時はエラー/未ログイン表示に寄せる方が安全です。
| export function DashboardContainer({ userId = 'default-user' }: DashboardContainerProps) { | |
| const [userStatus, setUserStatus] = useState<UserStatus | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| useEffect(() => { | |
| const loadDashboard = async () => { | |
| export function DashboardContainer({ userId }: DashboardContainerProps) { | |
| const [userStatus, setUserStatus] = useState<UserStatus | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| useEffect(() => { | |
| const loadDashboard = async () => { | |
| // userId が未指定の場合は安全側に倒してエラー扱いとし、API 呼び出しを行わない | |
| if (!userId) { | |
| setError('ユーザーIDが指定されていません'); | |
| setUserStatus(null); | |
| setLoading(false); | |
| return; | |
| } |
| export const metadata: Metadata = { | ||
| title: "Create Next App", | ||
| description: "Generated by create next app", | ||
| title: "Dashboard | Skill Tree", | ||
| description: "Your personal skill development dashboard", | ||
| }; |
There was a problem hiding this comment.
PR説明はダッシュボード機能追加が主旨ですが、アプリ全体の metadata(title/description)をダッシュボード前提の文言に変更しています。ホーム等の他ページも含めた全体影響があるので、ダッシュボード配下に限定する(app/(dashboard)/layout.tsx 側へ寄せる)か、PR説明に意図を明記してください。
| export interface Badge { | ||
| id: string; | ||
| name: string; | ||
| icon: string; | ||
| description: string; | ||
| unlockedAt?: Date; | ||
| } | ||
|
|
||
| export interface Rank { | ||
| level: number; | ||
| title: string; | ||
| progress: number; // 0-100 | ||
| nextLevelExp?: number; | ||
| } | ||
|
|
||
| export interface SkillNode { | ||
| id: string; | ||
| name: string; | ||
| description: string; | ||
| icon: string; | ||
| completed: boolean; | ||
| level: number; | ||
| prerequisites?: string[]; | ||
| } | ||
|
|
||
| export interface UserStatus { | ||
| userId: string; | ||
| displayName: string; | ||
| avatar?: string; | ||
| totalExp: number; | ||
| currentRank: Rank; | ||
| badges: Badge[]; | ||
| skillRoadmap: SkillNode[]; | ||
| joinedAt: Date; | ||
| lastActivityAt: Date; | ||
| } |
There was a problem hiding this comment.
unlockedAt / joinedAt / lastActivityAt を Date 型にしているため、将来バックエンドAPI(JSON)から取得する際に型と実データ(ISO文字列)にズレが出ます。API境界の型として使うなら string(ISO 8601)に寄せるか、string | Date としてデシリアライズ層(api内)で Date へ変換する形にしておくと移行が楽です。
| <html lang="ja"> | ||
| <body |
There was a problem hiding this comment.
<html lang="ja"> への変更はサイト全体の言語宣言に影響します(既存のホームは英語文言)。i18n方針が未確定なら、まずはダッシュボード配下のみに限定するか、全ページ文言/翻訳対応とセットで変更するのが安全です。
| import { DashboardContainer } from '@/features/dashboard/components/DashboardContainer'; | ||
|
|
||
| export default function DashboardPage() { | ||
| return <DashboardContainer userId="user-123" />; |
There was a problem hiding this comment.
ページ側で userId="user-123" を固定しており、PR説明の「ページレイヤーではロジックを持たない/責務を features に集約」と矛盾します。少なくとも固定値は避け、未指定(デフォルト)にするか、認証/ルートパラメータから取得する形に寄せてください。
| @@ -0,0 +1,17 @@ | |||
| /** | |||
| * Dashboard Layout | |||
| * ダッシュボード機能用のレイアウト(ロジックはなく、フロートのみ) | |||
There was a problem hiding this comment.
コメントの「フロートのみ」は表現として意味が取りづらく、意図(例: 「表示のみ」「レイアウトのみ」「ラッパーのみ」等)が伝わりません。内容に合う語に修正して、読む人が誤解しないようにしてください。
| * ダッシュボード機能用のレイアウト(ロジックはなく、フロートのみ) | |
| * ダッシュボード機能用のレイアウト(ロジックはなく、レイアウト定義のみ) |
|
|
||
| export const metadata = { |
There was a problem hiding this comment.
metadata が型付けされていないため、キー名のタイポ等がコンパイル時に検出されません。app/layout.tsx では Metadata 型を付与しているので、ここも同様に Metadata を付けて整合性を取ると安全です。
| export const metadata = { | |
| import type { Metadata } from 'next'; | |
| export const metadata: Metadata = { |
| </p> | ||
| <p className="mt-1 text-xs text-gray-600 dark:text-gray-400"> | ||
| {new Date(badge.unlockedAt!).toLocaleDateString('ja-JP')} | ||
| </p> |
There was a problem hiding this comment.
badge.unlockedAt! の non-null アサーションは、後続のリファクタや型変更時に破綻しやすいです。filter 側を型ガードにして unlockedAt が必ず存在する配列型に絞り込む形にすると安全に扱えます。
実装内容
ディレクトリ構成
frontend/src/features/dashboard/にダッシュボード機能を完全隔離components/- UIコンポーネント(StatusCard, BadgeList, SkillRoadmap, DashboardContainer)types/- ドメイン固有の型定義(UserStatus, Rank, Badge, SkillNode)api/- モックデータAPIコンポーネント
設計理由
app/(dashboard)ページレイヤーではロジックを持たず、features/dashboardに責務を集約セキュリティ考慮
タイプセーフティ
dark:class使用)実装の概要
技術的な意思決定と「なぜ」
セキュリティに関する自己評価
レビュワー(人間)への申し送り事項
備考
mock含め型定義部分は改修予定