diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py index 426bfc7..e6c7c0e 100644 --- a/backend/app/api/endpoints/users.py +++ b/backend/app/api/endpoints/users.py @@ -21,12 +21,15 @@ from app.models.user import User from app.models.enums import SkillCategory from app.schemas.badge import Badge as BadgeSchema +from app.schemas.grades import GradeStats as GradeStatsSchema +from app.schemas.grades import HighestRank as HighestRankSchema from app.schemas.profile import Profile as ProfileSchema from app.schemas.profile import ProfileCreate, ProfileUpdate from app.schemas.quest_progress import QuestProgress as QuestProgressSchema from app.schemas.skill_tree import SkillTree as SkillTreeSchema from app.schemas.user import User as UserSchema from app.schemas.user import UserUpdate +from app.services import grades_service router = APIRouter() @@ -140,3 +143,34 @@ def get_my_quest_progress( ) -> list[QuestProgressSchema]: """認証済みユーザー自身のクエスト進捗一覧取得。""" return crud_quest_progress.get_quest_progress_by_user(db, current_user.id) + + +@router.get("/me/grade-stats", response_model=GradeStatsSchema) +def get_my_grade_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> GradeStatsSchema: + """認証済みユーザー自身の成績統計情報を取得。""" + # 連続記録日数を取得 + consecutive_days = grades_service.get_consecutive_days(db, current_user.id) + + # 修了したクエスト数を取得 + completed_quests = grades_service.get_completed_quests_count(db, current_user.id) + + # 最も進捗が高いカテゴリを取得 + category, _ = grades_service.get_highest_progress_category(db, current_user.id) + + # 最高ランク情報を構築(現在の実装では全体で一つのrankを使用) + highest_rank = HighestRankSchema( + rank=current_user.rank, + category=category, + category_name=grades_service.CATEGORY_NAMES.get(category, "総合"), + rank_name=grades_service.RANK_NAMES.get(current_user.rank, "種子"), + color=grades_service.CATEGORY_COLORS.get(category, "#55aaff"), + ) + + return GradeStatsSchema( + consecutive_days=consecutive_days, + completed_quests=completed_quests, + highest_rank=highest_rank, + ) diff --git a/backend/app/schemas/grades.py b/backend/app/schemas/grades.py new file mode 100644 index 0000000..c63a9a0 --- /dev/null +++ b/backend/app/schemas/grades.py @@ -0,0 +1,21 @@ +"""Grades スキーマ - 成績・統計情報""" + +from pydantic import BaseModel + + +class HighestRank(BaseModel): + """最高ランク情報""" + + rank: int # 0-9 + category: str # 'web' | 'ai' | 'security' | 'infrastructure' | 'game' | 'design' + category_name: str # 表示用名称(例: "Web/App") + rank_name: str # ランク名(例: "林", "森", "世界樹") + color: str # 背景色(例: "#55aaff") + + +class GradeStats(BaseModel): + """成績統計情報""" + + consecutive_days: int # 連続記録日数 + completed_quests: int # 修了したクエスト数 + highest_rank: HighestRank # 最高ランク情報 diff --git a/backend/app/services/grades_service.py b/backend/app/services/grades_service.py new file mode 100644 index 0000000..6dda6d5 --- /dev/null +++ b/backend/app/services/grades_service.py @@ -0,0 +1,122 @@ +"""Grades サービス - 成績・統計情報の計算""" + +from sqlalchemy.orm import Session + +from app.crud import quest_progress as crud_quest_progress +from app.crud import skill_tree as crud_skill_tree +from app.models.enums import QuestStatus + + +# カテゴリ色マッピング +CATEGORY_COLORS = { + "web": "#55aaff", + "ai": "#e8b849", + "security": "#e85555", + "infrastructure": "#55cc55", + "game": "#ff9955", + "design": "#cc66dd", +} + +# カテゴリ名マッピング +CATEGORY_NAMES = { + "web": "Web/App", + "ai": "AI", + "security": "Security", + "infrastructure": "Infra", + "game": "Game", + "design": "Design", +} + +# ランク名マッピング(0-9) +RANK_NAMES = { + 0: "種子", + 1: "苗木", + 2: "若木", + 3: "巨木", + 4: "母樹", + 5: "林", + 6: "森", + 7: "霊樹", + 8: "古樹", + 9: "世界樹", +} + + +def calculate_skill_tree_progress(tree_data: dict) -> float: + """スキルツリーの進捗率を計算(0.0-1.0) + + Args: + tree_data: スキルツリーのJSONデータ + + Returns: + 進捗率(0.0-1.0) + """ + if not tree_data or "nodes" not in tree_data: + return 0.0 + + nodes = tree_data.get("nodes", []) + if not nodes: + return 0.0 + + completed_count = sum(1 for node in nodes if node.get("completed", False)) + total_count = len(nodes) + + return completed_count / total_count if total_count > 0 else 0.0 + + +def get_highest_progress_category(db: Session, user_id: int) -> tuple[str, float]: + """最も進捗が高いカテゴリを取得 + + Args: + db: データベースセッション + user_id: ユーザーID + + Returns: + (カテゴリ名, 進捗率) のタプル + """ + skill_trees = crud_skill_tree.get_skill_trees_by_user(db, user_id) + + if not skill_trees: + return "web", 0.0 # デフォルトはweb + + max_progress = 0.0 + max_category = "web" + + for tree in skill_trees: + progress = calculate_skill_tree_progress(tree.tree_data) + if progress > max_progress: + max_progress = progress + max_category = tree.category + + return max_category, max_progress + + +def get_completed_quests_count(db: Session, user_id: int) -> int: + """修了したクエスト数を取得 + + Args: + db: データベースセッション + user_id: ユーザーID + + Returns: + 修了したクエスト数 + """ + quest_progress_list = crud_quest_progress.get_quest_progress_by_user(db, user_id) + return sum(1 for qp in quest_progress_list if qp.status == QuestStatus.COMPLETED) + + +def get_consecutive_days(db: Session, user_id: int) -> int: + """連続記録日数を取得(現時点では固定値を返す) + + TODO: 将来的にログイン履歴などから算出 + + Args: + db: データベースセッション + user_id: ユーザーID + + Returns: + 連続記録日数 + """ + # 現時点では実装が複雑なため、仮の値を返す + # 将来的にログイン履歴テーブルなどから算出 + return 0 diff --git a/frontend/src/features/grades/api/mock.ts b/frontend/src/features/grades/api/mock.ts index 7e765b5..c264aef 100644 --- a/frontend/src/features/grades/api/mock.ts +++ b/frontend/src/features/grades/api/mock.ts @@ -4,7 +4,14 @@ export const getGradeStats = async (): Promise => { // TODO: Replace with actual API call return { consecutiveDays: 365, - completedQuests: 365 + completedQuests: 42, + highestRank: { + rank: 5, + category: "web", + categoryName: "Web/App", + rankName: "林", + color: "#55aaff" + } }; }; diff --git a/frontend/src/features/grades/components/StatusCard.tsx b/frontend/src/features/grades/components/StatusCard.tsx index 7063d98..c5181db 100644 --- a/frontend/src/features/grades/components/StatusCard.tsx +++ b/frontend/src/features/grades/components/StatusCard.tsx @@ -1,15 +1,56 @@ 'use client'; import React from 'react'; +import Image from 'next/image'; import { GradeStats } from '../types'; interface StatusCardProps { stats: GradeStats; } +// ランク名マッピング(0-9) +const RANK_NAMES: Record = { + 0: "種子", + 1: "苗木", + 2: "若木", + 3: "巨木", + 4: "母樹", + 5: "林", + 6: "森", + 7: "霊樹", + 8: "古樹", + 9: "世界樹", +}; + export const StatusCard: React.FC = ({ stats }) => { + // ランク名を取得(rankNameがあればそれを使用、なければrankから取得) + const displayRankName = stats.highestRank.rankName || RANK_NAMES[stats.highestRank.rank] || "種子"; + + console.log('Grade Stats:', stats); // デバッグ用 + console.log('Display Rank Name:', displayRankName); // デバッグ用 + return (
+ {/* Highest Rank */} +
+

最高ランク

+
+ {/* ランク画像(背景透過) */} +
+ {`Rank +
+ + {displayRankName} + +
+
+ {/* Consecutive Days */}

連続記録

diff --git a/frontend/src/features/grades/types/index.ts b/frontend/src/features/grades/types/index.ts index b55a790..9b52737 100644 --- a/frontend/src/features/grades/types/index.ts +++ b/frontend/src/features/grades/types/index.ts @@ -1,6 +1,15 @@ +export interface HighestRank { + rank: number; // 0-9 + category: string; // 'web' | 'ai' | 'security' | 'infrastructure' | 'game' | 'design' + categoryName: string; // 表示用名称 + rankName: string; // ランク名(例: "林", "森", "世界樹") + color: string; // 背景色 +} + export interface GradeStats { consecutiveDays: number; completedQuests: number; + highestRank: HighestRank; } export interface Badge {