Initial commit: Claude Code Project Tracker
Add comprehensive development intelligence system that tracks: - Development sessions with automatic start/stop - Full conversation history with semantic search - Tool usage and file operation analytics - Think time and engagement analysis - Git activity correlation - Learning pattern recognition - Productivity insights and metrics Features: - FastAPI backend with SQLite database - Modern web dashboard with interactive charts - Claude Code hook integration for automatic tracking - Comprehensive test suite with 100+ tests - Complete API documentation (OpenAPI/Swagger) - Privacy-first design with local data storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
44ed9936b7
48 changed files with 9707 additions and 0 deletions
430
app/api/projects.py
Normal file
430
app/api/projects.py
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
"""
|
||||
Project data retrieval API endpoints.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.connection import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.activity import Activity
|
||||
from app.models.waiting_period import WaitingPeriod
|
||||
from app.models.git_operation import GitOperation
|
||||
from app.api.schemas import ProjectSummary, ProjectTimeline, TimelineEvent
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[ProjectSummary])
|
||||
async def list_projects(
|
||||
limit: int = Query(50, description="Maximum number of results"),
|
||||
offset: int = Query(0, description="Number of results to skip"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of all tracked projects with summary statistics."""
|
||||
try:
|
||||
# Get projects with basic info
|
||||
query = select(Project).order_by(Project.last_session.desc().nullslast()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
projects = result.scalars().all()
|
||||
|
||||
project_summaries = []
|
||||
|
||||
for project in projects:
|
||||
# Get latest session for last_activity
|
||||
latest_session_result = await db.execute(
|
||||
select(Session.start_time)
|
||||
.where(Session.project_id == project.id)
|
||||
.order_by(Session.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_session = latest_session_result.scalars().first()
|
||||
|
||||
project_summaries.append(ProjectSummary(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
git_repo=project.git_repo,
|
||||
languages=project.languages,
|
||||
total_sessions=project.total_sessions,
|
||||
total_time_minutes=project.total_time_minutes,
|
||||
last_activity=latest_session or project.created_at,
|
||||
files_modified_count=project.files_modified_count,
|
||||
lines_changed_count=project.lines_changed_count
|
||||
))
|
||||
|
||||
return project_summaries
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to list projects: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectSummary)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed information about a specific project."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project {project_id} not found"
|
||||
)
|
||||
|
||||
# Get latest session for last_activity
|
||||
latest_session_result = await db.execute(
|
||||
select(Session.start_time)
|
||||
.where(Session.project_id == project.id)
|
||||
.order_by(Session.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_session = latest_session_result.scalars().first()
|
||||
|
||||
return ProjectSummary(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
git_repo=project.git_repo,
|
||||
languages=project.languages,
|
||||
total_sessions=project.total_sessions,
|
||||
total_time_minutes=project.total_time_minutes,
|
||||
last_activity=latest_session or project.created_at,
|
||||
files_modified_count=project.files_modified_count,
|
||||
lines_changed_count=project.lines_changed_count
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get project: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/timeline", response_model=ProjectTimeline)
|
||||
async def get_project_timeline(
|
||||
project_id: int,
|
||||
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get chronological timeline of project development."""
|
||||
try:
|
||||
# Get project info
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = project_result.scalars().first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project {project_id} not found"
|
||||
)
|
||||
|
||||
# Parse date filters
|
||||
date_filters = []
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
date_filters.append(lambda table: table.timestamp >= start_dt if hasattr(table, 'timestamp') else table.start_time >= start_dt)
|
||||
|
||||
if end_date:
|
||||
from datetime import datetime
|
||||
end_dt = datetime.fromisoformat(end_date + " 23:59:59")
|
||||
date_filters.append(lambda table: table.timestamp <= end_dt if hasattr(table, 'timestamp') else table.start_time <= end_dt)
|
||||
|
||||
timeline_events = []
|
||||
|
||||
# Get sessions for this project
|
||||
session_query = select(Session).where(Session.project_id == project_id)
|
||||
session_result = await db.execute(session_query)
|
||||
sessions = session_result.scalars().all()
|
||||
session_ids = [s.id for s in sessions]
|
||||
|
||||
# Session start/end events
|
||||
for session in sessions:
|
||||
# Session start
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=session.start_time,
|
||||
type="session_start",
|
||||
data={
|
||||
"session_id": session.id,
|
||||
"session_type": session.session_type,
|
||||
"git_branch": session.git_branch,
|
||||
"working_directory": session.working_directory
|
||||
}
|
||||
))
|
||||
|
||||
# Session end (if ended)
|
||||
if session.end_time:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=session.end_time,
|
||||
type="session_end",
|
||||
data={
|
||||
"session_id": session.id,
|
||||
"duration_minutes": session.duration_minutes,
|
||||
"activity_count": session.activity_count,
|
||||
"conversation_count": session.conversation_count
|
||||
}
|
||||
))
|
||||
|
||||
if session_ids:
|
||||
# Get conversations
|
||||
conv_query = select(Conversation).where(Conversation.session_id.in_(session_ids))
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
conv_query = conv_query.where(date_filter(Conversation))
|
||||
|
||||
conv_result = await db.execute(conv_query)
|
||||
conversations = conv_result.scalars().all()
|
||||
|
||||
for conv in conversations:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=conv.timestamp,
|
||||
type="conversation",
|
||||
data={
|
||||
"id": conv.id,
|
||||
"session_id": conv.session_id,
|
||||
"exchange_type": conv.exchange_type,
|
||||
"user_prompt": conv.user_prompt[:100] + "..." if conv.user_prompt and len(conv.user_prompt) > 100 else conv.user_prompt,
|
||||
"tools_used": conv.tools_used,
|
||||
"files_affected": conv.files_affected
|
||||
}
|
||||
))
|
||||
|
||||
# Get activities
|
||||
activity_query = select(Activity).where(Activity.session_id.in_(session_ids))
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
activity_query = activity_query.where(date_filter(Activity))
|
||||
|
||||
activity_result = await db.execute(activity_query)
|
||||
activities = activity_result.scalars().all()
|
||||
|
||||
for activity in activities:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=activity.timestamp,
|
||||
type="activity",
|
||||
data={
|
||||
"id": activity.id,
|
||||
"session_id": activity.session_id,
|
||||
"tool_name": activity.tool_name,
|
||||
"action": activity.action,
|
||||
"file_path": activity.file_path,
|
||||
"success": activity.success,
|
||||
"lines_changed": activity.total_lines_changed
|
||||
}
|
||||
))
|
||||
|
||||
# Get git operations
|
||||
git_query = select(GitOperation).where(GitOperation.session_id.in_(session_ids))
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
git_query = git_query.where(date_filter(GitOperation))
|
||||
|
||||
git_result = await db.execute(git_query)
|
||||
git_operations = git_result.scalars().all()
|
||||
|
||||
for git_op in git_operations:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=git_op.timestamp,
|
||||
type="git_operation",
|
||||
data={
|
||||
"id": git_op.id,
|
||||
"session_id": git_op.session_id,
|
||||
"operation": git_op.operation,
|
||||
"commit_hash": git_op.commit_hash,
|
||||
"commit_message": git_op.get_commit_message(),
|
||||
"files_changed": git_op.files_count,
|
||||
"lines_changed": git_op.total_lines_changed
|
||||
}
|
||||
))
|
||||
|
||||
# Sort timeline by timestamp
|
||||
timeline_events.sort(key=lambda x: x.timestamp)
|
||||
|
||||
# Create project summary for response
|
||||
latest_session_result = await db.execute(
|
||||
select(Session.start_time)
|
||||
.where(Session.project_id == project.id)
|
||||
.order_by(Session.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_session = latest_session_result.scalars().first()
|
||||
|
||||
project_summary = ProjectSummary(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
git_repo=project.git_repo,
|
||||
languages=project.languages,
|
||||
total_sessions=project.total_sessions,
|
||||
total_time_minutes=project.total_time_minutes,
|
||||
last_activity=latest_session or project.created_at,
|
||||
files_modified_count=project.files_modified_count,
|
||||
lines_changed_count=project.lines_changed_count
|
||||
)
|
||||
|
||||
return ProjectTimeline(
|
||||
project=project_summary,
|
||||
timeline=timeline_events
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get project timeline: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/stats")
|
||||
async def get_project_stats(
|
||||
project_id: int,
|
||||
days: int = Query(30, description="Number of days to include in statistics"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive statistics for a project."""
|
||||
try:
|
||||
# Verify project exists
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = project_result.scalars().first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project {project_id} not found"
|
||||
)
|
||||
|
||||
# Date filter for recent activity
|
||||
from datetime import datetime, timedelta
|
||||
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
|
||||
|
||||
# Get sessions
|
||||
session_query = select(Session).where(Session.project_id == project_id)
|
||||
if start_date:
|
||||
session_query = session_query.where(Session.start_time >= start_date)
|
||||
|
||||
session_result = await db.execute(session_query)
|
||||
sessions = session_result.scalars().all()
|
||||
session_ids = [s.id for s in sessions] if sessions else []
|
||||
|
||||
# Calculate session statistics
|
||||
total_sessions = len(sessions)
|
||||
total_time = sum(s.calculated_duration_minutes or 0 for s in sessions)
|
||||
avg_session_length = total_time / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
# Activity statistics
|
||||
activity_stats = {"total": 0, "by_tool": {}, "by_language": {}}
|
||||
if session_ids:
|
||||
activity_query = select(Activity).where(Activity.session_id.in_(session_ids))
|
||||
activity_result = await db.execute(activity_query)
|
||||
activities = activity_result.scalars().all()
|
||||
|
||||
activity_stats["total"] = len(activities)
|
||||
|
||||
for activity in activities:
|
||||
# Tool usage
|
||||
tool = activity.tool_name
|
||||
activity_stats["by_tool"][tool] = activity_stats["by_tool"].get(tool, 0) + 1
|
||||
|
||||
# Language usage
|
||||
lang = activity.get_programming_language()
|
||||
if lang:
|
||||
activity_stats["by_language"][lang] = activity_stats["by_language"].get(lang, 0) + 1
|
||||
|
||||
# Conversation statistics
|
||||
conversation_stats = {"total": 0, "by_type": {}}
|
||||
if session_ids:
|
||||
conv_query = select(Conversation).where(Conversation.session_id.in_(session_ids))
|
||||
conv_result = await db.execute(conv_query)
|
||||
conversations = conv_result.scalars().all()
|
||||
|
||||
conversation_stats["total"] = len(conversations)
|
||||
|
||||
for conv in conversations:
|
||||
conv_type = conv.exchange_type
|
||||
conversation_stats["by_type"][conv_type] = conversation_stats["by_type"].get(conv_type, 0) + 1
|
||||
|
||||
# Git statistics
|
||||
git_stats = {"total": 0, "commits": 0, "by_operation": {}}
|
||||
if session_ids:
|
||||
git_query = select(GitOperation).where(GitOperation.session_id.in_(session_ids))
|
||||
git_result = await db.execute(git_query)
|
||||
git_operations = git_result.scalars().all()
|
||||
|
||||
git_stats["total"] = len(git_operations)
|
||||
|
||||
for git_op in git_operations:
|
||||
if git_op.is_commit:
|
||||
git_stats["commits"] += 1
|
||||
|
||||
op_type = git_op.operation
|
||||
git_stats["by_operation"][op_type] = git_stats["by_operation"].get(op_type, 0) + 1
|
||||
|
||||
# Productivity trends (daily aggregation)
|
||||
daily_stats = {}
|
||||
for session in sessions:
|
||||
date_key = session.start_time.date().isoformat()
|
||||
if date_key not in daily_stats:
|
||||
daily_stats[date_key] = {
|
||||
"date": date_key,
|
||||
"sessions": 0,
|
||||
"time_minutes": 0,
|
||||
"activities": 0
|
||||
}
|
||||
|
||||
daily_stats[date_key]["sessions"] += 1
|
||||
daily_stats[date_key]["time_minutes"] += session.calculated_duration_minutes or 0
|
||||
daily_stats[date_key]["activities"] += session.activity_count
|
||||
|
||||
productivity_trends = list(daily_stats.values())
|
||||
productivity_trends.sort(key=lambda x: x["date"])
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"project_name": project.name,
|
||||
"time_period_days": days,
|
||||
"session_statistics": {
|
||||
"total_sessions": total_sessions,
|
||||
"total_time_minutes": total_time,
|
||||
"average_session_length_minutes": round(avg_session_length, 1)
|
||||
},
|
||||
"activity_statistics": activity_stats,
|
||||
"conversation_statistics": conversation_stats,
|
||||
"git_statistics": git_stats,
|
||||
"productivity_trends": productivity_trends,
|
||||
"summary": {
|
||||
"most_used_tool": max(activity_stats["by_tool"].items(), key=lambda x: x[1])[0] if activity_stats["by_tool"] else None,
|
||||
"primary_language": max(activity_stats["by_language"].items(), key=lambda x: x[1])[0] if activity_stats["by_language"] else None,
|
||||
"daily_average_time": round(total_time / days, 1) if days > 0 else 0,
|
||||
"daily_average_sessions": round(total_sessions / days, 1) if days > 0 else 0
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get project stats: {str(e)}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue