Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Dashboard Layout
* ダッシュボード機能用のレイアウト(ロジックはなく、フロートのみ)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

コメントの「フロートのみ」は表現として意味が取りづらく、意図(例: 「表示のみ」「レイアウトのみ」「ラッパーのみ」等)が伝わりません。内容に合う語に修正して、読む人が誤解しないようにしてください。

Suggested change
* ダッシュボード機能用のレイアウト(ロジックはなく、フロートのみ
* ダッシュボード機能用のレイアウト(ロジックはなく、レイアウト定義のみ

Copilot uses AI. Check for mistakes.
*/

export const metadata = {
Comment on lines +5 to +6
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

metadata が型付けされていないため、キー名のタイポ等がコンパイル時に検出されません。app/layout.tsx では Metadata 型を付与しているので、ここも同様に Metadata を付けて整合性を取ると安全です。

Suggested change
export const metadata = {
import type { Metadata } from 'next';
export const metadata: Metadata = {

Copilot uses AI. Check for mistakes.
title: 'Dashboard',
description: 'Your personal dashboard',
};

export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
10 changes: 10 additions & 0 deletions frontend/src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Dashboard Page
* ダッシュボード機能のメインページ
*/

import { DashboardContainer } from '@/features/dashboard/components/DashboardContainer';

export default function DashboardPage() {
return <DashboardContainer userId="user-123" />;
Comment on lines +6 to +9
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

ページ側で userId="user-123" を固定しており、PR説明の「ページレイヤーではロジックを持たない/責務を features に集約」と矛盾します。少なくとも固定値は避け、未指定(デフォルト)にするか、認証/ルートパラメータから取得する形に寄せてください。

Copilot uses AI. Check for mistakes.
}
6 changes: 3 additions & 3 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Dashboard | Skill Tree",
description: "Your personal skill development dashboard",
};
Comment on lines 15 to 18
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

PR説明はダッシュボード機能追加が主旨ですが、アプリ全体の metadata(title/description)をダッシュボード前提の文言に変更しています。ホーム等の他ページも含めた全体影響があるので、ダッシュボード配下に限定する(app/(dashboard)/layout.tsx 側へ寄せる)か、PR説明に意図を明記してください。

Copilot uses AI. Check for mistakes.

export default function RootLayout({
Expand All @@ -23,7 +23,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="ja">
<body
Comment on lines +26 to 27
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

<html lang="ja"> への変更はサイト全体の言語宣言に影響します(既存のホームは英語文言)。i18n方針が未確定なら、まずはダッシュボード配下のみに限定するか、全ページ文言/翻訳対応とセットで変更するのが安全です。

Copilot uses AI. Check for mistakes.
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
Expand Down
94 changes: 94 additions & 0 deletions frontend/src/features/dashboard/api/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Dashboard Feature Mock Data
* この機能専用のモックデータを返す関数
*/

import type { UserStatus, Badge, SkillNode, Rank } from '../types';

const mockBadges: Badge[] = [
{
id: 'badge-1',
name: 'Beginner',
icon: '🌱',
description: '初心者バッジ',
unlockedAt: new Date('2024-01-15'),
},
{
id: 'badge-2',
name: 'Explorer',
icon: '🧭',
description: 'エクスプローラーバッジ',
unlockedAt: new Date('2024-02-20'),
},
{
id: 'badge-3',
name: 'Master',
icon: '⭐',
description: 'マスターバッジ',
},
];

const mockSkills: SkillNode[] = [
{
id: 'skill-1',
name: 'TypeScript基礎',
description: 'TypeScriptの基本を学ぶ',
icon: '📘',
completed: true,
level: 5,
prerequisites: [],
},
{
id: 'skill-2',
name: 'React基礎',
description: 'Reactの基本を学ぶ',
icon: '⚛️',
completed: true,
level: 4,
prerequisites: ['skill-1'],
},
{
id: 'skill-3',
name: 'Next.js応用',
description: 'Next.jsの応用技術を学ぶ',
icon: '🚀',
completed: true,
level: 3,
prerequisites: ['skill-2'],
},
{
id: 'skill-4',
name: 'デプロイメント',
description: 'アプリケーションのデプロイ方法を学ぶ',
icon: '🛸',
completed: false,
level: 1,
prerequisites: ['skill-3'],
},
];

const mockRank: Rank = {
level: 12,
title: 'Senior Developer',
progress: 65,
nextLevelExp: 5000,
};

/**
* ユーザーのダッシュボード情報を取得(モック)
*/
export async function fetchUserDashboard(userId: string): Promise<UserStatus> {
// 実装では、ここでバックエンドAPIを呼び出す
// await fetch(`/api/users/${userId}/dashboard`)
return {
userId,
displayName: 'Sample User',
avatar: '👨‍💻',
totalExp: 12300,
currentRank: mockRank,
badges: mockBadges,
skillRoadmap: mockSkills,
joinedAt: new Date('2023-06-15'),
lastActivityAt: new Date(),
};
}
75 changes: 75 additions & 0 deletions frontend/src/features/dashboard/components/BadgeList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

/**
* BadgeList Component
* 獲得したバッジ一覧と未獲得バッジを表示
*/

import type { Badge } from '../types';

interface BadgeListProps {
badges: Badge[];
}

export function BadgeList({ badges }: BadgeListProps) {
const unlockedBadges = badges.filter((badge) => badge.unlockedAt);
const lockedBadges = badges.filter((badge) => !badge.unlockedAt);

return (
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
🏅 バッジ
</h2>

{/* 獲得済みバッジ */}
{unlockedBadges.length > 0 && (
<div className="mb-6">
<h3 className="mb-3 text-sm font-semibold uppercase text-gray-600 dark:text-gray-400">
獲得済み({unlockedBadges.length})
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{unlockedBadges.map((badge) => (
<div
key={badge.id}
className="flex flex-col items-center rounded-lg bg-gradient-to-br from-yellow-50 to-orange-50 p-4 transition-transform hover:scale-105 dark:from-yellow-900/20 dark:to-orange-900/20"
>
<div className="text-4xl">{badge.icon}</div>
<p className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{badge.name}
</p>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{new Date(badge.unlockedAt!).toLocaleDateString('ja-JP')}
</p>
Comment on lines +39 to +42
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

badge.unlockedAt! の non-null アサーションは、後続のリファクタや型変更時に破綻しやすいです。filter 側を型ガードにして unlockedAt が必ず存在する配列型に絞り込む形にすると安全に扱えます。

Copilot uses AI. Check for mistakes.
</div>
))}
</div>
</div>
)}

{/* 未獲得バッジ */}
{lockedBadges.length > 0 && (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase text-gray-600 dark:text-gray-400">
未獲得({lockedBadges.length})
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{lockedBadges.map((badge) => (
<div
key={badge.id}
className="flex flex-col items-center rounded-lg bg-gray-100 p-4 opacity-50 dark:bg-gray-800"
>
<div className="text-4xl opacity-50">{badge.icon}</div>
<p className="mt-2 text-sm font-medium text-gray-500 dark:text-gray-400">
{badge.name}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500">
近日対応
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
82 changes: 82 additions & 0 deletions frontend/src/features/dashboard/components/DashboardContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client';

/**
* DashboardContainer Component
* ダッシュボードの全コンポーネントを統合し、データフェッチを担当
*/

import { useEffect, useState } from 'react';
import type { UserStatus } from '../types';
import { fetchUserDashboard } from '../api/mock';
import { StatusCard } from './StatusCard';
import { BadgeList } from './BadgeList';
import { SkillRoadmap } from './SkillRoadmap';

interface DashboardContainerProps {
userId?: string;
}

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 () => {
Comment on lines +19 to +25
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

userId のデフォルト値が固定文字列(default-user)だと、呼び出し側が渡し忘れた場合に意図せず別ユーザーのダッシュボードとして扱われます。必須にするか、未指定時はエラー/未ログイン表示に寄せる方が安全です。

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

Copilot uses AI. Check for mistakes.
try {
setLoading(true);
const data = await fetchUserDashboard(userId);
setUserStatus(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : 'ダッシュボードの読み込みに失敗しました'
);
} finally {
setLoading(false);
}
};

loadDashboard();
Comment on lines +25 to +40
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

useEffect 内の非同期処理がコンポーネントのアンマウント後も setState し得るため、Reactの警告や不要な更新の原因になります。クリーンアップでキャンセルフラグ(または AbortController)を扱い、アンマウント後は state 更新しないようにしてください。

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

Copilot uses AI. Check for mistakes.
}, [userId]);

if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="mb-4 inline-flex h-12 w-12 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400" />
<p className="text-gray-600 dark:text-gray-400">
ダッシュボードを読み込み中...
</p>
</div>
</div>
);
}

if (error || !userStatus) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="rounded-lg bg-white p-8 text-center shadow-lg dark:bg-gray-800">
<p className="text-red-600 dark:text-red-400">
{error || 'ダッシュボードの読み込みに失敗しました'}
</p>
</div>
</div>
);
}

return (
<main className="min-h-screen bg-gray-50 px-4 py-8 dark:bg-gray-900">
<div className="mx-auto max-w-6xl space-y-8">
{/* ステータスカード */}
<StatusCard userStatus={userStatus} />

{/* バッジ一覧 */}
<BadgeList badges={userStatus.badges} />

{/* スキルロードマップ */}
<SkillRoadmap skills={userStatus.skillRoadmap} />
</div>
</main>
);
}
Loading
Loading