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:
Ryan Malloy 2025-08-11 02:59:21 -06:00
commit 44ed9936b7
48 changed files with 9707 additions and 0 deletions

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@
# Claude Code Project Tracker

3
app/api/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
API modules for the Claude Code Project Tracker.
"""

304
app/api/activities.py Normal file
View file

@ -0,0 +1,304 @@
"""
Activity tracking 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.activity import Activity
from app.models.session import Session
from app.api.schemas import ActivityRequest, ActivityResponse
router = APIRouter()
@router.post("/activity", response_model=ActivityResponse, status_code=status.HTTP_201_CREATED)
async def record_activity(
request: ActivityRequest,
db: AsyncSession = Depends(get_db)
):
"""
Record a development activity (tool usage, file operation, etc.).
This endpoint is called by Claude Code PostToolUse hooks.
"""
try:
# Verify session exists
result = await db.execute(
select(Session).where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
# Create activity record
activity = Activity(
session_id=request.session_id,
conversation_id=request.conversation_id,
timestamp=request.timestamp,
tool_name=request.tool_name,
action=request.action,
file_path=request.file_path,
metadata=request.metadata,
success=request.success,
error_message=request.error_message,
lines_added=request.lines_added,
lines_removed=request.lines_removed
)
db.add(activity)
# Update session activity count
session.add_activity()
# Add file to session's touched files if applicable
if request.file_path:
session.add_file_touched(request.file_path)
await db.commit()
await db.refresh(activity)
return ActivityResponse(
id=activity.id,
session_id=activity.session_id,
tool_name=activity.tool_name,
action=activity.action,
timestamp=activity.timestamp,
success=activity.success
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to record activity: {str(e)}"
)
@router.get("/activities")
async def get_activities(
session_id: Optional[int] = Query(None, description="Filter by session ID"),
tool_name: Optional[str] = Query(None, description="Filter by tool name"),
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 activities with optional filtering."""
try:
query = select(Activity).options(
selectinload(Activity.session).selectinload(Session.project)
)
# Apply filters
if session_id:
query = query.where(Activity.session_id == session_id)
if tool_name:
query = query.where(Activity.tool_name == tool_name)
# Order by timestamp descending
query = query.order_by(Activity.timestamp.desc()).offset(offset).limit(limit)
result = await db.execute(query)
activities = result.scalars().all()
return [
{
"id": activity.id,
"session_id": activity.session_id,
"project_name": activity.session.project.name,
"timestamp": activity.timestamp,
"tool_name": activity.tool_name,
"action": activity.action,
"file_path": activity.file_path,
"success": activity.success,
"programming_language": activity.get_programming_language(),
"lines_changed": activity.total_lines_changed,
"metadata": activity.metadata
}
for activity in activities
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get activities: {str(e)}"
)
@router.get("/activities/{activity_id}")
async def get_activity(
activity_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific activity."""
try:
result = await db.execute(
select(Activity)
.options(
selectinload(Activity.session).selectinload(Session.project),
selectinload(Activity.conversation)
)
.where(Activity.id == activity_id)
)
activity = result.scalars().first()
if not activity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Activity {activity_id} not found"
)
return {
"id": activity.id,
"session_id": activity.session_id,
"conversation_id": activity.conversation_id,
"project_name": activity.session.project.name,
"timestamp": activity.timestamp,
"tool_name": activity.tool_name,
"action": activity.action,
"file_path": activity.file_path,
"metadata": activity.metadata,
"success": activity.success,
"error_message": activity.error_message,
"lines_added": activity.lines_added,
"lines_removed": activity.lines_removed,
"total_lines_changed": activity.total_lines_changed,
"net_lines_changed": activity.net_lines_changed,
"file_extension": activity.get_file_extension(),
"programming_language": activity.get_programming_language(),
"is_file_operation": activity.is_file_operation,
"is_code_execution": activity.is_code_execution,
"is_search_operation": activity.is_search_operation,
"command_executed": activity.get_command_executed(),
"search_pattern": activity.get_search_pattern(),
"task_type": activity.get_task_type()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get activity: {str(e)}"
)
@router.get("/activities/stats/tools")
async def get_tool_usage_stats(
session_id: Optional[int] = Query(None, description="Filter by session ID"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get tool usage statistics."""
try:
# Base query for tool usage counts
query = select(
Activity.tool_name,
func.count(Activity.id).label('usage_count'),
func.count(func.distinct(Activity.session_id)).label('sessions_used'),
func.avg(Activity.lines_added + Activity.lines_removed).label('avg_lines_changed'),
func.sum(Activity.lines_added + Activity.lines_removed).label('total_lines_changed')
).group_by(Activity.tool_name)
# Apply filters
if session_id:
query = query.where(Activity.session_id == session_id)
elif project_id:
query = query.join(Session).where(Session.project_id == project_id)
# Filter by date range
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(Activity.timestamp >= start_date)
result = await db.execute(query)
stats = result.all()
return [
{
"tool_name": stat.tool_name,
"usage_count": stat.usage_count,
"sessions_used": stat.sessions_used,
"avg_lines_changed": float(stat.avg_lines_changed or 0),
"total_lines_changed": stat.total_lines_changed or 0
}
for stat in stats
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get tool usage stats: {str(e)}"
)
@router.get("/activities/stats/languages")
async def get_language_usage_stats(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get programming language usage statistics."""
try:
# Get activities with file operations
query = select(Activity).where(
Activity.file_path.isnot(None),
Activity.tool_name.in_(["Edit", "Write", "Read"])
)
# Apply filters
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(Activity.timestamp >= start_date)
result = await db.execute(query)
activities = result.scalars().all()
# Count by programming language
language_stats = {}
for activity in activities:
lang = activity.get_programming_language()
if lang:
if lang not in language_stats:
language_stats[lang] = {
"language": lang,
"file_count": 0,
"activity_count": 0,
"lines_added": 0,
"lines_removed": 0
}
language_stats[lang]["activity_count"] += 1
language_stats[lang]["lines_added"] += activity.lines_added or 0
language_stats[lang]["lines_removed"] += activity.lines_removed or 0
# Count unique files per language
for activity in activities:
lang = activity.get_programming_language()
if lang and lang in language_stats:
# This is a rough approximation - in reality we'd need to track unique files
language_stats[lang]["file_count"] += 1
return list(language_stats.values())
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get language usage stats: {str(e)}"
)

483
app/api/analytics.py Normal file
View file

@ -0,0 +1,483 @@
"""
Analytics API endpoints for productivity insights and metrics.
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
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 ProductivityMetrics
router = APIRouter()
async def calculate_engagement_score(waiting_periods: List[WaitingPeriod]) -> float:
"""Calculate overall engagement score from waiting periods."""
if not waiting_periods:
return 75.0 # Default neutral score
scores = [wp.engagement_score for wp in waiting_periods]
avg_score = sum(scores) / len(scores)
return round(avg_score * 100, 1) # Convert to 0-100 scale
async def get_productivity_trends(db: AsyncSession, sessions: List[Session], days: int) -> List[Dict[str, Any]]:
"""Calculate daily productivity trends."""
from datetime import datetime, timedelta
# Group sessions by date
daily_data = {}
for session in sessions:
date_key = session.start_time.date().isoformat()
if date_key not in daily_data:
daily_data[date_key] = {
"sessions": 0,
"total_time": 0,
"activities": 0,
"conversations": 0
}
daily_data[date_key]["sessions"] += 1
daily_data[date_key]["total_time"] += session.calculated_duration_minutes or 0
daily_data[date_key]["activities"] += session.activity_count
daily_data[date_key]["conversations"] += session.conversation_count
# Calculate productivity scores (0-100 based on relative activity)
if daily_data:
max_activities = max(day["activities"] for day in daily_data.values()) or 1
max_time = max(day["total_time"] for day in daily_data.values()) or 1
trends = []
for date, data in sorted(daily_data.items()):
# Weighted score: 60% activities, 40% time
activity_score = (data["activities"] / max_activities) * 60
time_score = (data["total_time"] / max_time) * 40
productivity_score = activity_score + time_score
trends.append({
"date": date,
"score": round(productivity_score, 1)
})
return trends
return []
@router.get("/analytics/productivity", response_model=ProductivityMetrics)
async def get_productivity_metrics(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to analyze"),
db: AsyncSession = Depends(get_db)
):
"""
Get comprehensive productivity analytics and insights.
Analyzes engagement, tool usage, and productivity patterns.
"""
try:
from datetime import datetime, timedelta
# Date filter
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
# Base query for sessions
session_query = select(Session).options(
selectinload(Session.project),
selectinload(Session.activities),
selectinload(Session.conversations),
selectinload(Session.waiting_periods)
)
if project_id:
session_query = session_query.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()
if not sessions:
return ProductivityMetrics(
engagement_score=0.0,
average_session_length=0.0,
think_time_average=0.0,
files_per_session=0.0,
tools_most_used=[],
productivity_trends=[]
)
# Calculate basic metrics
total_sessions = len(sessions)
total_time = sum(s.calculated_duration_minutes or 0 for s in sessions)
average_session_length = total_time / total_sessions if total_sessions > 0 else 0
# Collect all waiting periods for engagement analysis
all_waiting_periods = []
for session in sessions:
all_waiting_periods.extend(session.waiting_periods)
# Calculate think time average
valid_wait_times = [wp.calculated_duration_seconds for wp in all_waiting_periods
if wp.calculated_duration_seconds is not None]
think_time_average = sum(valid_wait_times) / len(valid_wait_times) if valid_wait_times else 0
# Calculate engagement score
engagement_score = await calculate_engagement_score(all_waiting_periods)
# Calculate files per session
total_files = sum(len(s.files_touched or []) for s in sessions)
files_per_session = total_files / total_sessions if total_sessions > 0 else 0
# Tool usage analysis
tool_usage = {}
for session in sessions:
for activity in session.activities:
tool = activity.tool_name
if tool not in tool_usage:
tool_usage[tool] = 0
tool_usage[tool] += 1
tools_most_used = [
{"tool": tool, "count": count}
for tool, count in sorted(tool_usage.items(), key=lambda x: x[1], reverse=True)[:10]
]
# Get productivity trends
productivity_trends = await get_productivity_trends(db, sessions, days)
return ProductivityMetrics(
engagement_score=engagement_score,
average_session_length=round(average_session_length, 1),
think_time_average=round(think_time_average, 1),
files_per_session=round(files_per_session, 1),
tools_most_used=tools_most_used,
productivity_trends=productivity_trends
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get productivity metrics: {str(e)}"
)
@router.get("/analytics/patterns")
async def get_development_patterns(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to analyze"),
db: AsyncSession = Depends(get_db)
):
"""Analyze development patterns and workflow insights."""
try:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
# Get sessions with related data
session_query = select(Session).options(
selectinload(Session.activities),
selectinload(Session.conversations),
selectinload(Session.waiting_periods),
selectinload(Session.git_operations)
)
if project_id:
session_query = session_query.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()
if not sessions:
return {"message": "No data available for the specified period"}
# Working hours analysis
hour_distribution = {}
for session in sessions:
hour = session.start_time.hour
hour_distribution[hour] = hour_distribution.get(hour, 0) + 1
# Session type patterns
session_type_distribution = {}
for session in sessions:
session_type = session.session_type
session_type_distribution[session_type] = session_type_distribution.get(session_type, 0) + 1
# Git workflow patterns
git_patterns = {"commits_per_session": 0, "commit_frequency": {}}
total_commits = 0
commit_days = set()
for session in sessions:
session_commits = sum(1 for op in session.git_operations if op.is_commit)
total_commits += session_commits
for op in session.git_operations:
if op.is_commit:
commit_days.add(op.timestamp.date())
git_patterns["commits_per_session"] = round(total_commits / len(sessions), 2) if sessions else 0
git_patterns["commit_frequency"] = round(len(commit_days) / days, 2) if days > 0 else 0
# Problem-solving patterns
problem_solving = {"debug_sessions": 0, "learning_sessions": 0, "implementation_sessions": 0}
for session in sessions:
# Analyze conversation content to infer session type
debug_keywords = ["error", "debug", "bug", "fix", "problem", "issue"]
learn_keywords = ["how", "what", "explain", "understand", "learn", "tutorial"]
impl_keywords = ["implement", "create", "build", "add", "feature"]
session_content = " ".join([
conv.user_prompt or "" for conv in session.conversations
]).lower()
if any(keyword in session_content for keyword in debug_keywords):
problem_solving["debug_sessions"] += 1
elif any(keyword in session_content for keyword in learn_keywords):
problem_solving["learning_sessions"] += 1
elif any(keyword in session_content for keyword in impl_keywords):
problem_solving["implementation_sessions"] += 1
# Tool workflow patterns
common_sequences = {}
for session in sessions:
activities = sorted(session.activities, key=lambda a: a.timestamp)
if len(activities) >= 2:
for i in range(len(activities) - 1):
sequence = f"{activities[i].tool_name}{activities[i+1].tool_name}"
common_sequences[sequence] = common_sequences.get(sequence, 0) + 1
# Get top 5 tool sequences
top_sequences = sorted(common_sequences.items(), key=lambda x: x[1], reverse=True)[:5]
return {
"analysis_period_days": days,
"total_sessions_analyzed": len(sessions),
"working_hours": {
"distribution": hour_distribution,
"peak_hours": sorted(hour_distribution.items(), key=lambda x: x[1], reverse=True)[:3],
"most_active_hour": max(hour_distribution.items(), key=lambda x: x[1])[0] if hour_distribution else None
},
"session_patterns": {
"type_distribution": session_type_distribution,
"average_duration_minutes": round(sum(s.calculated_duration_minutes or 0 for s in sessions) / len(sessions), 1)
},
"git_workflow": git_patterns,
"problem_solving_patterns": problem_solving,
"tool_workflows": {
"common_sequences": [{"sequence": seq, "count": count} for seq, count in top_sequences],
"total_unique_sequences": len(common_sequences)
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to analyze development patterns: {str(e)}"
)
@router.get("/analytics/learning")
async def get_learning_insights(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to analyze"),
db: AsyncSession = Depends(get_db)
):
"""Analyze learning patterns and knowledge development."""
try:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
# Get conversations for learning analysis
conv_query = select(Conversation).options(selectinload(Conversation.session))
if project_id:
conv_query = conv_query.join(Session).where(Session.project_id == project_id)
if start_date:
conv_query = conv_query.where(Conversation.timestamp >= start_date)
conv_result = await db.execute(conv_query)
conversations = conv_result.scalars().all()
if not conversations:
return {"message": "No conversation data available for learning analysis"}
# Topic frequency analysis
learning_keywords = {
"authentication": ["auth", "login", "password", "token", "session"],
"database": ["database", "sql", "query", "table", "migration"],
"api": ["api", "rest", "endpoint", "request", "response"],
"testing": ["test", "pytest", "unittest", "mock", "fixture"],
"deployment": ["deploy", "docker", "aws", "server", "production"],
"debugging": ["debug", "error", "exception", "traceback", "log"],
"optimization": ["optimize", "performance", "speed", "memory", "cache"],
"security": ["security", "vulnerability", "encrypt", "hash", "ssl"]
}
topic_frequency = {topic: 0 for topic in learning_keywords.keys()}
for conv in conversations:
if conv.user_prompt:
prompt_lower = conv.user_prompt.lower()
for topic, keywords in learning_keywords.items():
if any(keyword in prompt_lower for keyword in keywords):
topic_frequency[topic] += 1
# Question complexity analysis
complexity_indicators = {
"beginner": ["how to", "what is", "how do i", "basic", "simple"],
"intermediate": ["best practice", "optimize", "improve", "better way"],
"advanced": ["architecture", "pattern", "scalability", "design", "system"]
}
complexity_distribution = {level: 0 for level in complexity_indicators.keys()}
for conv in conversations:
if conv.user_prompt:
prompt_lower = conv.user_prompt.lower()
for level, indicators in complexity_indicators.items():
if any(indicator in prompt_lower for indicator in indicators):
complexity_distribution[level] += 1
break
# Learning progression analysis
weekly_topics = {}
for conv in conversations:
if conv.user_prompt:
week = conv.timestamp.strftime("%Y-W%U")
if week not in weekly_topics:
weekly_topics[week] = set()
prompt_lower = conv.user_prompt.lower()
for topic, keywords in learning_keywords.items():
if any(keyword in prompt_lower for keyword in keywords):
weekly_topics[week].add(topic)
# Calculate learning velocity (new topics per week)
learning_velocity = []
for week, topics in sorted(weekly_topics.items()):
learning_velocity.append({
"week": week,
"new_topics": len(topics),
"topics": list(topics)
})
# Repetition patterns (topics asked about multiple times)
repeated_topics = {topic: count for topic, count in topic_frequency.items() if count > 1}
return {
"analysis_period_days": days,
"total_conversations_analyzed": len(conversations),
"learning_topics": {
"frequency": topic_frequency,
"most_discussed": max(topic_frequency.items(), key=lambda x: x[1]) if topic_frequency else None,
"repeated_topics": repeated_topics
},
"question_complexity": {
"distribution": complexity_distribution,
"progression_indicator": "advancing" if complexity_distribution["advanced"] > complexity_distribution["beginner"] else "learning_basics"
},
"learning_velocity": learning_velocity,
"insights": {
"diverse_learning": len([t for t, c in topic_frequency.items() if c > 0]),
"deep_dives": len(repeated_topics),
"active_learning_weeks": len(weekly_topics),
"avg_topics_per_week": round(sum(len(topics) for topics in weekly_topics.values()) / len(weekly_topics), 1) if weekly_topics else 0
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to analyze learning insights: {str(e)}"
)
@router.get("/analytics/summary")
async def get_analytics_summary(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
db: AsyncSession = Depends(get_db)
):
"""Get a high-level analytics summary dashboard."""
try:
# Get overall statistics
base_query = select(Session)
if project_id:
base_query = base_query.where(Session.project_id == project_id)
session_result = await db.execute(base_query)
all_sessions = session_result.scalars().all()
if not all_sessions:
return {"message": "No data available"}
# Basic metrics
total_sessions = len(all_sessions)
total_time_hours = sum(s.calculated_duration_minutes or 0 for s in all_sessions) / 60
avg_session_minutes = (sum(s.calculated_duration_minutes or 0 for s in all_sessions) / total_sessions) if total_sessions else 0
# Date range
start_date = min(s.start_time for s in all_sessions)
end_date = max(s.start_time for s in all_sessions)
total_days = (end_date - start_date).days + 1
# Activity summary
total_activities = sum(s.activity_count for s in all_sessions)
total_conversations = sum(s.conversation_count for s in all_sessions)
# Recent activity (last 7 days)
from datetime import datetime, timedelta
week_ago = datetime.utcnow() - timedelta(days=7)
recent_sessions = [s for s in all_sessions if s.start_time >= week_ago]
# Project diversity (if not filtered by project)
project_count = 1 if project_id else len(set(s.project_id for s in all_sessions))
return {
"overview": {
"total_sessions": total_sessions,
"total_time_hours": round(total_time_hours, 1),
"average_session_minutes": round(avg_session_minutes, 1),
"total_activities": total_activities,
"total_conversations": total_conversations,
"projects_tracked": project_count,
"tracking_period_days": total_days
},
"recent_activity": {
"sessions_last_7_days": len(recent_sessions),
"time_last_7_days_hours": round(sum(s.calculated_duration_minutes or 0 for s in recent_sessions) / 60, 1),
"daily_average_last_week": round(len(recent_sessions) / 7, 1)
},
"productivity_indicators": {
"activities_per_session": round(total_activities / total_sessions, 1) if total_sessions else 0,
"conversations_per_session": round(total_conversations / total_sessions, 1) if total_sessions else 0,
"productivity_score": min(100, round((total_activities / total_sessions) * 5, 1)) if total_sessions else 0 # Rough scoring
},
"time_distribution": {
"daily_average_hours": round(total_time_hours / total_days, 1),
"longest_session_minutes": max(s.calculated_duration_minutes or 0 for s in all_sessions),
"shortest_session_minutes": min(s.calculated_duration_minutes or 0 for s in all_sessions if s.calculated_duration_minutes)
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get analytics summary: {str(e)}"
)

220
app/api/conversations.py Normal file
View file

@ -0,0 +1,220 @@
"""
Conversation tracking 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, or_, func
from sqlalchemy.orm import selectinload
from app.database.connection import get_db
from app.models.conversation import Conversation
from app.models.session import Session
from app.api.schemas import ConversationRequest, ConversationResponse, ConversationSearchResult
router = APIRouter()
@router.post("/conversation", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
async def log_conversation(
request: ConversationRequest,
db: AsyncSession = Depends(get_db)
):
"""
Log a conversation exchange between user and Claude.
This endpoint is called by Claude Code hooks to capture dialogue.
"""
try:
# Verify session exists
result = await db.execute(
select(Session).where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
# Create conversation entry
conversation = Conversation(
session_id=request.session_id,
timestamp=request.timestamp,
user_prompt=request.user_prompt,
claude_response=request.claude_response,
tools_used=request.tools_used,
files_affected=request.files_affected,
context=request.context,
tokens_input=request.tokens_input,
tokens_output=request.tokens_output,
exchange_type=request.exchange_type
)
db.add(conversation)
# Update session conversation count
session.add_conversation()
# Add files to session's touched files list
if request.files_affected:
for file_path in request.files_affected:
session.add_file_touched(file_path)
await db.commit()
await db.refresh(conversation)
return ConversationResponse(
id=conversation.id,
session_id=conversation.session_id,
timestamp=conversation.timestamp,
exchange_type=conversation.exchange_type,
content_length=conversation.content_length
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to log conversation: {str(e)}"
)
@router.get("/conversations/search", response_model=List[ConversationSearchResult])
async def search_conversations(
query: str = Query(..., description="Search query"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
limit: int = Query(20, description="Maximum number of results"),
db: AsyncSession = Depends(get_db)
):
"""
Search through conversation history.
Performs text search across user prompts and Claude responses.
"""
try:
# Build search query
search_query = select(Conversation).options(
selectinload(Conversation.session).selectinload(Session.project)
)
# Add text search conditions
search_conditions = []
search_terms = query.lower().split()
for term in search_terms:
term_condition = or_(
func.lower(Conversation.user_prompt).contains(term),
func.lower(Conversation.claude_response).contains(term)
)
search_conditions.append(term_condition)
if search_conditions:
search_query = search_query.where(or_(*search_conditions))
# Add project filter if specified
if project_id:
search_query = search_query.join(Session).where(Session.project_id == project_id)
# Order by timestamp descending and limit results
search_query = search_query.order_by(Conversation.timestamp.desc()).limit(limit)
result = await db.execute(search_query)
conversations = result.scalars().all()
# Build search results with relevance scoring
results = []
for conversation in conversations:
# Simple relevance scoring based on term matches
relevance_score = 0.0
content = (conversation.user_prompt or "") + " " + (conversation.claude_response or "")
content_lower = content.lower()
for term in search_terms:
relevance_score += content_lower.count(term) / len(search_terms)
# Normalize score (rough approximation)
relevance_score = min(relevance_score / 10, 1.0)
# Extract context snippets
context_snippets = []
for term in search_terms:
if term in content_lower:
start_idx = content_lower.find(term)
start = max(0, start_idx - 50)
end = min(len(content), start_idx + len(term) + 50)
snippet = content[start:end].strip()
if snippet and snippet not in context_snippets:
context_snippets.append(snippet)
results.append(ConversationSearchResult(
id=conversation.id,
project_name=conversation.session.project.name,
timestamp=conversation.timestamp,
user_prompt=conversation.user_prompt,
claude_response=conversation.claude_response,
relevance_score=relevance_score,
context=context_snippets[:3] # Limit to 3 snippets
))
# Sort by relevance score descending
results.sort(key=lambda x: x.relevance_score, reverse=True)
return results
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to search conversations: {str(e)}"
)
@router.get("/conversations/{conversation_id}")
async def get_conversation(
conversation_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific conversation."""
try:
result = await db.execute(
select(Conversation)
.options(selectinload(Conversation.session).selectinload(Session.project))
.where(Conversation.id == conversation_id)
)
conversation = result.scalars().first()
if not conversation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conversation {conversation_id} not found"
)
return {
"id": conversation.id,
"session_id": conversation.session_id,
"project_name": conversation.session.project.name,
"timestamp": conversation.timestamp,
"user_prompt": conversation.user_prompt,
"claude_response": conversation.claude_response,
"tools_used": conversation.tools_used,
"files_affected": conversation.files_affected,
"context": conversation.context,
"exchange_type": conversation.exchange_type,
"content_length": conversation.content_length,
"estimated_tokens": conversation.estimated_tokens,
"intent_category": conversation.get_intent_category(),
"complexity_level": conversation.get_complexity_level(),
"has_file_operations": conversation.has_file_operations(),
"has_code_execution": conversation.has_code_execution()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get conversation: {str(e)}"
)

360
app/api/git.py Normal file
View file

@ -0,0 +1,360 @@
"""
Git operation tracking 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.git_operation import GitOperation
from app.models.session import Session
from app.api.schemas import GitOperationRequest, GitOperationResponse
router = APIRouter()
@router.post("/git", response_model=GitOperationResponse, status_code=status.HTTP_201_CREATED)
async def record_git_operation(
request: GitOperationRequest,
db: AsyncSession = Depends(get_db)
):
"""
Record a git operation performed during development.
Called by hooks when git commands are executed via Bash tool.
"""
try:
# Verify session exists
result = await db.execute(
select(Session).where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
# Create git operation record
git_operation = GitOperation(
session_id=request.session_id,
timestamp=request.timestamp,
operation=request.operation,
command=request.command,
result=request.result,
success=request.success,
files_changed=request.files_changed,
lines_added=request.lines_added,
lines_removed=request.lines_removed,
commit_hash=request.commit_hash,
branch_from=request.branch_from,
branch_to=request.branch_to
)
db.add(git_operation)
await db.commit()
await db.refresh(git_operation)
return GitOperationResponse(
id=git_operation.id,
session_id=git_operation.session_id,
operation=git_operation.operation,
timestamp=git_operation.timestamp,
success=git_operation.success,
commit_hash=git_operation.commit_hash
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to record git operation: {str(e)}"
)
@router.get("/git/operations")
async def get_git_operations(
session_id: Optional[int] = Query(None, description="Filter by session ID"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
operation: Optional[str] = Query(None, description="Filter by operation type"),
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 git operations with optional filtering."""
try:
query = select(GitOperation).options(
selectinload(GitOperation.session).selectinload(Session.project)
)
# Apply filters
if session_id:
query = query.where(GitOperation.session_id == session_id)
elif project_id:
query = query.join(Session).where(Session.project_id == project_id)
if operation:
query = query.where(GitOperation.operation == operation)
# Order by timestamp descending
query = query.order_by(GitOperation.timestamp.desc()).offset(offset).limit(limit)
result = await db.execute(query)
git_operations = result.scalars().all()
return [
{
"id": op.id,
"session_id": op.session_id,
"project_name": op.session.project.name,
"timestamp": op.timestamp,
"operation": op.operation,
"command": op.command,
"result": op.result,
"success": op.success,
"files_changed": op.files_changed,
"files_count": op.files_count,
"lines_added": op.lines_added,
"lines_removed": op.lines_removed,
"total_lines_changed": op.total_lines_changed,
"net_lines_changed": op.net_lines_changed,
"commit_hash": op.commit_hash,
"commit_message": op.get_commit_message(),
"commit_category": op.get_commit_category(),
"change_size_category": op.get_change_size_category()
}
for op in git_operations
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get git operations: {str(e)}"
)
@router.get("/git/operations/{operation_id}")
async def get_git_operation(
operation_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific git operation."""
try:
result = await db.execute(
select(GitOperation)
.options(selectinload(GitOperation.session).selectinload(Session.project))
.where(GitOperation.id == operation_id)
)
git_operation = result.scalars().first()
if not git_operation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Git operation {operation_id} not found"
)
return {
"id": git_operation.id,
"session_id": git_operation.session_id,
"project_name": git_operation.session.project.name,
"timestamp": git_operation.timestamp,
"operation": git_operation.operation,
"command": git_operation.command,
"result": git_operation.result,
"success": git_operation.success,
"files_changed": git_operation.files_changed,
"files_count": git_operation.files_count,
"lines_added": git_operation.lines_added,
"lines_removed": git_operation.lines_removed,
"total_lines_changed": git_operation.total_lines_changed,
"net_lines_changed": git_operation.net_lines_changed,
"commit_hash": git_operation.commit_hash,
"branch_from": git_operation.branch_from,
"branch_to": git_operation.branch_to,
"commit_message": git_operation.get_commit_message(),
"branch_name": git_operation.get_branch_name(),
"is_commit": git_operation.is_commit,
"is_push": git_operation.is_push,
"is_pull": git_operation.is_pull,
"is_branch_operation": git_operation.is_branch_operation,
"is_merge_commit": git_operation.is_merge_commit(),
"is_feature_commit": git_operation.is_feature_commit(),
"is_bugfix_commit": git_operation.is_bugfix_commit(),
"is_refactor_commit": git_operation.is_refactor_commit(),
"commit_category": git_operation.get_commit_category(),
"change_size_category": git_operation.get_change_size_category()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get git operation: {str(e)}"
)
@router.get("/git/stats/commits")
async def get_commit_stats(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get commit statistics and patterns."""
try:
# Base query for commits
query = select(GitOperation).where(GitOperation.operation == "commit")
# Apply filters
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(GitOperation.timestamp >= start_date)
result = await db.execute(query)
commits = result.scalars().all()
if not commits:
return {
"total_commits": 0,
"commit_categories": {},
"change_size_distribution": {},
"average_lines_per_commit": 0.0,
"commit_frequency": []
}
# Calculate statistics
total_commits = len(commits)
# Categorize commits
categories = {}
for commit in commits:
category = commit.get_commit_category()
categories[category] = categories.get(category, 0) + 1
# Change size distribution
size_distribution = {}
for commit in commits:
size_category = commit.get_change_size_category()
size_distribution[size_category] = size_distribution.get(size_category, 0) + 1
# Average lines per commit
total_lines = sum(commit.total_lines_changed for commit in commits)
avg_lines_per_commit = total_lines / total_commits if total_commits > 0 else 0
# Daily commit frequency
daily_commits = {}
for commit in commits:
date_key = commit.timestamp.date().isoformat()
daily_commits[date_key] = daily_commits.get(date_key, 0) + 1
commit_frequency = [
{"date": date, "commits": count}
for date, count in sorted(daily_commits.items())
]
return {
"total_commits": total_commits,
"commit_categories": categories,
"change_size_distribution": size_distribution,
"average_lines_per_commit": round(avg_lines_per_commit, 1),
"commit_frequency": commit_frequency,
"top_commit_messages": [
{
"message": commit.get_commit_message(),
"lines_changed": commit.total_lines_changed,
"timestamp": commit.timestamp
}
for commit in sorted(commits, key=lambda c: c.total_lines_changed, reverse=True)[:5]
if commit.get_commit_message()
]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get commit stats: {str(e)}"
)
@router.get("/git/stats/activity")
async def get_git_activity_stats(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get overall git activity statistics."""
try:
query = select(GitOperation)
# Apply filters
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(GitOperation.timestamp >= start_date)
result = await db.execute(query)
operations = result.scalars().all()
if not operations:
return {
"total_operations": 0,
"operations_by_type": {},
"success_rate": 0.0,
"most_active_days": []
}
# Operations by type
operations_by_type = {}
successful_operations = 0
for op in operations:
operations_by_type[op.operation] = operations_by_type.get(op.operation, 0) + 1
if op.success:
successful_operations += 1
success_rate = successful_operations / len(operations) * 100 if operations else 0
# Daily activity
daily_activity = {}
for op in operations:
date_key = op.timestamp.date().isoformat()
if date_key not in daily_activity:
daily_activity[date_key] = 0
daily_activity[date_key] += 1
most_active_days = [
{"date": date, "operations": count}
for date, count in sorted(daily_activity.items(), key=lambda x: x[1], reverse=True)[:7]
]
return {
"total_operations": len(operations),
"operations_by_type": operations_by_type,
"success_rate": round(success_rate, 1),
"most_active_days": most_active_days,
"timeline": [
{
"date": date,
"operations": count
}
for date, count in sorted(daily_activity.items())
]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get git activity stats: {str(e)}"
)

430
app/api/projects.py Normal file
View 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)}"
)

220
app/api/schemas.py Normal file
View file

@ -0,0 +1,220 @@
"""
Pydantic schemas for API request/response models.
"""
from datetime import datetime
from typing import Optional, List, Dict, Any, Union
from pydantic import BaseModel, Field
# Base schemas
class TimestampMixin(BaseModel):
"""Mixin for timestamp fields."""
created_at: datetime
updated_at: datetime
# Session schemas
class SessionStartRequest(BaseModel):
"""Request schema for starting a session."""
session_type: str = Field(..., description="Type of session: startup, resume, or clear")
working_directory: str = Field(..., description="Current working directory")
git_branch: Optional[str] = Field(None, description="Current git branch")
git_repo: Optional[str] = Field(None, description="Git repository URL")
environment: Optional[Dict[str, Any]] = Field(None, description="Environment variables and context")
class SessionEndRequest(BaseModel):
"""Request schema for ending a session."""
session_id: int = Field(..., description="ID of the session to end")
end_reason: str = Field(default="normal", description="Reason for ending: normal, interrupted, or timeout")
class SessionResponse(BaseModel):
"""Response schema for session operations."""
session_id: int
project_id: int
status: str
message: Optional[str] = None
class Config:
from_attributes = True
# Conversation schemas
class ConversationRequest(BaseModel):
"""Request schema for logging conversations."""
session_id: int = Field(..., description="Associated session ID")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the exchange occurred")
user_prompt: Optional[str] = Field(None, description="User's input message")
claude_response: Optional[str] = Field(None, description="Claude's response")
tools_used: Optional[List[str]] = Field(None, description="Tools used in the response")
files_affected: Optional[List[str]] = Field(None, description="Files mentioned or modified")
context: Optional[Dict[str, Any]] = Field(None, description="Additional context")
tokens_input: Optional[int] = Field(None, description="Estimated input token count")
tokens_output: Optional[int] = Field(None, description="Estimated output token count")
exchange_type: str = Field(..., description="Type of exchange: user_prompt or claude_response")
class ConversationResponse(BaseModel):
"""Response schema for conversation operations."""
id: int
session_id: int
timestamp: datetime
exchange_type: str
content_length: int
class Config:
from_attributes = True
# Activity schemas
class ActivityRequest(BaseModel):
"""Request schema for recording activities."""
session_id: int = Field(..., description="Associated session ID")
conversation_id: Optional[int] = Field(None, description="Associated conversation ID")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the activity occurred")
tool_name: str = Field(..., description="Name of the tool used")
action: str = Field(..., description="Specific action taken")
file_path: Optional[str] = Field(None, description="Target file path if applicable")
metadata: Optional[Dict[str, Any]] = Field(None, description="Tool-specific metadata")
success: bool = Field(default=True, description="Whether the operation succeeded")
error_message: Optional[str] = Field(None, description="Error details if failed")
lines_added: Optional[int] = Field(None, description="Lines added for Edit/Write operations")
lines_removed: Optional[int] = Field(None, description="Lines removed for Edit operations")
class ActivityResponse(BaseModel):
"""Response schema for activity operations."""
id: int
session_id: int
tool_name: str
action: str
timestamp: datetime
success: bool
class Config:
from_attributes = True
# Waiting period schemas
class WaitingStartRequest(BaseModel):
"""Request schema for starting a waiting period."""
session_id: int = Field(..., description="Associated session ID")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When waiting started")
context_before: Optional[str] = Field(None, description="Context before waiting")
class WaitingEndRequest(BaseModel):
"""Request schema for ending a waiting period."""
session_id: int = Field(..., description="Associated session ID")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When waiting ended")
duration_seconds: Optional[int] = Field(None, description="Total waiting duration")
context_after: Optional[str] = Field(None, description="Context after waiting")
class WaitingPeriodResponse(BaseModel):
"""Response schema for waiting period operations."""
id: int
session_id: int
start_time: datetime
end_time: Optional[datetime]
duration_seconds: Optional[int]
engagement_score: float
class Config:
from_attributes = True
# Git operation schemas
class GitOperationRequest(BaseModel):
"""Request schema for git operations."""
session_id: int = Field(..., description="Associated session ID")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the operation occurred")
operation: str = Field(..., description="Type of git operation")
command: str = Field(..., description="Full git command executed")
result: Optional[str] = Field(None, description="Command output")
success: bool = Field(default=True, description="Whether the command succeeded")
files_changed: Optional[List[str]] = Field(None, description="Files affected by the operation")
lines_added: Optional[int] = Field(None, description="Lines added")
lines_removed: Optional[int] = Field(None, description="Lines removed")
commit_hash: Optional[str] = Field(None, description="Git commit SHA")
branch_from: Optional[str] = Field(None, description="Source branch")
branch_to: Optional[str] = Field(None, description="Target branch")
class GitOperationResponse(BaseModel):
"""Response schema for git operations."""
id: int
session_id: int
operation: str
timestamp: datetime
success: bool
commit_hash: Optional[str]
class Config:
from_attributes = True
# Project schemas
class ProjectSummary(BaseModel):
"""Summary information about a project."""
id: int
name: str
path: str
git_repo: Optional[str]
languages: Optional[List[str]]
total_sessions: int
total_time_minutes: int
last_activity: Optional[datetime]
files_modified_count: int
lines_changed_count: int
class Config:
from_attributes = True
class TimelineEvent(BaseModel):
"""Individual event in project timeline."""
timestamp: datetime
type: str # session_start, session_end, conversation, activity, git_operation
data: Dict[str, Any]
class ProjectTimeline(BaseModel):
"""Project timeline with events."""
project: ProjectSummary
timeline: List[TimelineEvent]
# Analytics schemas
class ProductivityMetrics(BaseModel):
"""Productivity analytics response."""
engagement_score: float = Field(..., description="Overall engagement level (0-100)")
average_session_length: float = Field(..., description="Minutes per session")
think_time_average: float = Field(..., description="Average waiting time between interactions")
files_per_session: float = Field(..., description="Average files touched per session")
tools_most_used: List[Dict[str, Union[str, int]]] = Field(..., description="Most frequently used tools")
productivity_trends: List[Dict[str, Union[str, float]]] = Field(..., description="Daily productivity scores")
class ConversationSearchResult(BaseModel):
"""Search result for conversation search."""
id: int
project_name: str
timestamp: datetime
user_prompt: Optional[str]
claude_response: Optional[str]
relevance_score: float
context: List[str]
class Config:
from_attributes = True
# Error schemas
class ErrorResponse(BaseModel):
"""Standard error response."""
error: str
message: str
details: Optional[Dict[str, Any]] = None

255
app/api/sessions.py Normal file
View file

@ -0,0 +1,255 @@
"""
Session management API endpoints.
"""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.api.schemas import SessionStartRequest, SessionEndRequest, SessionResponse
router = APIRouter()
async def get_or_create_project(
db: AsyncSession,
working_directory: str,
git_repo: Optional[str] = None
) -> Project:
"""Get existing project or create a new one based on working directory."""
# Try to find existing project by path
result = await db.execute(
select(Project).where(Project.path == working_directory)
)
project = result.scalars().first()
if project:
return project
# Create new project
project_name = os.path.basename(working_directory) or "Unknown Project"
# Try to infer languages from directory (simple heuristic)
languages = []
try:
for root, dirs, files in os.walk(working_directory):
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext == ".py":
languages.append("python")
elif ext in [".js", ".jsx"]:
languages.append("javascript")
elif ext in [".ts", ".tsx"]:
languages.append("typescript")
elif ext == ".go":
languages.append("go")
elif ext == ".rs":
languages.append("rust")
elif ext == ".java":
languages.append("java")
elif ext in [".cpp", ".cc", ".cxx"]:
languages.append("cpp")
elif ext == ".c":
languages.append("c")
# Don't traverse too deep
if len(root.replace(working_directory, "").split(os.sep)) > 2:
break
languages = list(set(languages))[:5] # Keep unique, limit to 5
except (OSError, PermissionError):
# If we can't read the directory, that's okay
pass
project = Project(
name=project_name,
path=working_directory,
git_repo=git_repo,
languages=languages if languages else None
)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.post("/session/start", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
request: SessionStartRequest,
db: AsyncSession = Depends(get_db)
):
"""
Start a new development session.
This endpoint is called by Claude Code hooks when a session begins.
It will create a project if one doesn't exist for the working directory.
"""
try:
# Get or create project
project = await get_or_create_project(
db=db,
working_directory=request.working_directory,
git_repo=request.git_repo
)
# Create new session
session = Session(
project_id=project.id,
session_type=request.session_type,
working_directory=request.working_directory,
git_branch=request.git_branch,
environment=request.environment
)
db.add(session)
await db.commit()
await db.refresh(session)
# Store session ID in temp file for hooks to use
session_file = "/tmp/claude-session-id"
try:
with open(session_file, "w") as f:
f.write(str(session.id))
except OSError:
# If we can't write the session file, log but don't fail
pass
return SessionResponse(
session_id=session.id,
project_id=project.id,
status="started",
message=f"Session started for project '{project.name}'"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start session: {str(e)}"
)
@router.post("/session/end", response_model=SessionResponse)
async def end_session(
request: SessionEndRequest,
db: AsyncSession = Depends(get_db)
):
"""
End an active development session.
This endpoint calculates final session statistics and updates
project-level metrics.
"""
try:
# Find the session
result = await db.execute(
select(Session)
.options(selectinload(Session.project))
.where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
if not session.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already ended"
)
# End the session
session.end_session(end_reason=request.end_reason)
await db.commit()
# Clean up session ID file
session_file = "/tmp/claude-session-id"
try:
if os.path.exists(session_file):
os.remove(session_file)
except OSError:
pass
return SessionResponse(
session_id=session.id,
project_id=session.project_id,
status="ended",
message=f"Session ended after {session.duration_minutes} minutes"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to end session: {str(e)}"
)
@router.get("/sessions/{session_id}", response_model=dict)
async def get_session(
session_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific session."""
try:
result = await db.execute(
select(Session)
.options(
selectinload(Session.project),
selectinload(Session.conversations),
selectinload(Session.activities),
selectinload(Session.waiting_periods),
selectinload(Session.git_operations)
)
.where(Session.id == session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
return {
"id": session.id,
"project": {
"id": session.project.id,
"name": session.project.name,
"path": session.project.path
},
"start_time": session.start_time,
"end_time": session.end_time,
"duration_minutes": session.calculated_duration_minutes,
"session_type": session.session_type,
"git_branch": session.git_branch,
"activity_count": session.activity_count,
"conversation_count": session.conversation_count,
"is_active": session.is_active,
"statistics": {
"conversations": len(session.conversations),
"activities": len(session.activities),
"waiting_periods": len(session.waiting_periods),
"git_operations": len(session.git_operations),
"files_touched": len(session.files_touched or [])
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get session: {str(e)}"
)

285
app/api/waiting.py Normal file
View file

@ -0,0 +1,285 @@
"""
Waiting period tracking 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.waiting_period import WaitingPeriod
from app.models.session import Session
from app.api.schemas import WaitingStartRequest, WaitingEndRequest, WaitingPeriodResponse
router = APIRouter()
@router.post("/waiting/start", response_model=WaitingPeriodResponse, status_code=status.HTTP_201_CREATED)
async def start_waiting_period(
request: WaitingStartRequest,
db: AsyncSession = Depends(get_db)
):
"""
Start a new waiting period.
Called by the Notification hook when Claude is waiting for user input.
"""
try:
# Verify session exists
result = await db.execute(
select(Session).where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
# Check if there's already an active waiting period for this session
active_waiting = await db.execute(
select(WaitingPeriod).where(
WaitingPeriod.session_id == request.session_id,
WaitingPeriod.end_time.is_(None)
)
)
existing = active_waiting.scalars().first()
if existing:
# End the existing waiting period first
existing.end_waiting()
# Create new waiting period
waiting_period = WaitingPeriod(
session_id=request.session_id,
start_time=request.timestamp,
context_before=request.context_before
)
db.add(waiting_period)
await db.commit()
await db.refresh(waiting_period)
return WaitingPeriodResponse(
id=waiting_period.id,
session_id=waiting_period.session_id,
start_time=waiting_period.start_time,
end_time=waiting_period.end_time,
duration_seconds=waiting_period.calculated_duration_seconds,
engagement_score=waiting_period.engagement_score
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start waiting period: {str(e)}"
)
@router.post("/waiting/end", response_model=WaitingPeriodResponse)
async def end_waiting_period(
request: WaitingEndRequest,
db: AsyncSession = Depends(get_db)
):
"""
End the current waiting period for a session.
Called when the user submits new input or the Stop hook triggers.
"""
try:
# Find the active waiting period for this session
result = await db.execute(
select(WaitingPeriod).where(
WaitingPeriod.session_id == request.session_id,
WaitingPeriod.end_time.is_(None)
)
)
waiting_period = result.scalars().first()
if not waiting_period:
# If no active waiting period, that's okay - just return success
return WaitingPeriodResponse(
id=0,
session_id=request.session_id,
start_time=request.timestamp,
end_time=request.timestamp,
duration_seconds=0,
engagement_score=1.0
)
# End the waiting period
waiting_period.end_time = request.timestamp
waiting_period.duration_seconds = request.duration_seconds or waiting_period.calculated_duration_seconds
waiting_period.context_after = request.context_after
# Classify the activity based on duration
waiting_period.likely_activity = waiting_period.classify_activity()
await db.commit()
await db.refresh(waiting_period)
return WaitingPeriodResponse(
id=waiting_period.id,
session_id=waiting_period.session_id,
start_time=waiting_period.start_time,
end_time=waiting_period.end_time,
duration_seconds=waiting_period.duration_seconds,
engagement_score=waiting_period.engagement_score
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to end waiting period: {str(e)}"
)
@router.get("/waiting/periods")
async def get_waiting_periods(
session_id: Optional[int] = Query(None, description="Filter by session ID"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
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 waiting periods with optional filtering."""
try:
query = select(WaitingPeriod).options(
selectinload(WaitingPeriod.session).selectinload(Session.project)
)
# Apply filters
if session_id:
query = query.where(WaitingPeriod.session_id == session_id)
elif project_id:
query = query.join(Session).where(Session.project_id == project_id)
# Order by start time descending
query = query.order_by(WaitingPeriod.start_time.desc()).offset(offset).limit(limit)
result = await db.execute(query)
waiting_periods = result.scalars().all()
return [
{
"id": period.id,
"session_id": period.session_id,
"project_name": period.session.project.name,
"start_time": period.start_time,
"end_time": period.end_time,
"duration_seconds": period.calculated_duration_seconds,
"duration_minutes": period.duration_minutes,
"likely_activity": period.classify_activity(),
"engagement_score": period.engagement_score,
"context_before": period.context_before,
"context_after": period.context_after,
"is_quick_response": period.is_quick_response(),
"is_thoughtful_pause": period.is_thoughtful_pause(),
"is_research_break": period.is_research_break(),
"is_extended_break": period.is_extended_break()
}
for period in waiting_periods
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get waiting periods: {str(e)}"
)
@router.get("/waiting/stats/engagement")
async def get_engagement_stats(
session_id: Optional[int] = Query(None, description="Filter by session ID"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(7, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get engagement statistics based on waiting periods."""
try:
# Base query for waiting periods
query = select(WaitingPeriod).where(WaitingPeriod.duration_seconds.isnot(None))
# Apply filters
if session_id:
query = query.where(WaitingPeriod.session_id == session_id)
elif project_id:
query = query.join(Session).where(Session.project_id == project_id)
# Filter by date range
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(WaitingPeriod.start_time >= start_date)
result = await db.execute(query)
waiting_periods = result.scalars().all()
if not waiting_periods:
return {
"total_periods": 0,
"average_engagement_score": 0.0,
"average_think_time_seconds": 0.0,
"activity_distribution": {},
"engagement_trends": []
}
# Calculate statistics
total_periods = len(waiting_periods)
engagement_scores = [period.engagement_score for period in waiting_periods]
durations = [period.duration_seconds for period in waiting_periods]
average_engagement = sum(engagement_scores) / len(engagement_scores)
average_think_time = sum(durations) / len(durations)
# Activity distribution
activity_counts = {}
for period in waiting_periods:
activity = period.classify_activity()
activity_counts[activity] = activity_counts.get(activity, 0) + 1
activity_distribution = {
activity: count / total_periods
for activity, count in activity_counts.items()
}
# Engagement trends (daily averages)
daily_engagement = {}
for period in waiting_periods:
date_key = period.start_time.date().isoformat()
if date_key not in daily_engagement:
daily_engagement[date_key] = []
daily_engagement[date_key].append(period.engagement_score)
engagement_trends = [
{
"date": date,
"engagement_score": sum(scores) / len(scores)
}
for date, scores in sorted(daily_engagement.items())
]
return {
"total_periods": total_periods,
"average_engagement_score": round(average_engagement, 3),
"average_think_time_seconds": round(average_think_time, 1),
"activity_distribution": activity_distribution,
"engagement_trends": engagement_trends,
"response_time_breakdown": {
"quick_responses": sum(1 for p in waiting_periods if p.is_quick_response()),
"thoughtful_pauses": sum(1 for p in waiting_periods if p.is_thoughtful_pause()),
"research_breaks": sum(1 for p in waiting_periods if p.is_research_break()),
"extended_breaks": sum(1 for p in waiting_periods if p.is_extended_break())
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get engagement stats: {str(e)}"
)

View file

@ -0,0 +1,3 @@
"""
Web dashboard for the Claude Code Project Tracker.
"""

49
app/dashboard/routes.py Normal file
View file

@ -0,0 +1,49 @@
"""
Dashboard web interface routes.
"""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.connection import get_db
dashboard_router = APIRouter()
templates = Jinja2Templates(directory="app/dashboard/templates")
@dashboard_router.get("/dashboard", response_class=HTMLResponse)
async def dashboard_home(request: Request, db: AsyncSession = Depends(get_db)):
"""Main dashboard page."""
return templates.TemplateResponse("dashboard.html", {
"request": request,
"title": "Claude Code Project Tracker"
})
@dashboard_router.get("/dashboard/projects", response_class=HTMLResponse)
async def dashboard_projects(request: Request, db: AsyncSession = Depends(get_db)):
"""Projects overview page."""
return templates.TemplateResponse("projects.html", {
"request": request,
"title": "Projects - Claude Code Tracker"
})
@dashboard_router.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics(request: Request, db: AsyncSession = Depends(get_db)):
"""Analytics and insights page."""
return templates.TemplateResponse("analytics.html", {
"request": request,
"title": "Analytics - Claude Code Tracker"
})
@dashboard_router.get("/dashboard/conversations", response_class=HTMLResponse)
async def dashboard_conversations(request: Request, db: AsyncSession = Depends(get_db)):
"""Conversation search and history page."""
return templates.TemplateResponse("conversations.html", {
"request": request,
"title": "Conversations - Claude Code Tracker"
})

View file

@ -0,0 +1,346 @@
/* Claude Code Project Tracker Dashboard Styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--info-color: #17a2b8;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-color: #f8f9fa;
--dark-color: #343a40;
}
/* Global Styles */
body {
background-color: #f5f5f5;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Navigation */
.navbar-brand {
font-weight: 600;
}
.navbar-nav .nav-link {
font-weight: 500;
transition: color 0.3s ease;
}
.navbar-nav .nav-link:hover {
color: #fff !important;
}
/* Cards */
.card {
border: none;
border-radius: 0.75rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.card-header {
background-color: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
font-weight: 600;
}
/* Status Cards */
.card.bg-primary,
.card.bg-success,
.card.bg-info,
.card.bg-warning,
.card.bg-danger {
border: none;
}
.card.bg-primary .card-body,
.card.bg-success .card-body,
.card.bg-info .card-body,
.card.bg-warning .card-body,
.card.bg-danger .card-body {
padding: 1.5rem;
}
/* Charts */
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
/* Tables */
.table {
margin-bottom: 0;
}
.table th {
border-top: none;
font-weight: 600;
color: var(--dark-color);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table td {
vertical-align: middle;
font-size: 0.9rem;
}
/* Badges */
.badge {
font-size: 0.75rem;
font-weight: 500;
}
/* Project List */
.project-item {
border-left: 4px solid var(--primary-color);
background-color: #fff;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.3s ease;
}
.project-item:hover {
transform: translateX(5px);
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
}
.project-title {
font-weight: 600;
color: var(--dark-color);
margin-bottom: 0.25rem;
}
.project-path {
color: var(--secondary-color);
font-size: 0.875rem;
font-family: 'Courier New', monospace;
}
.project-stats {
font-size: 0.8rem;
color: var(--secondary-color);
}
/* Activity Timeline */
.activity-timeline {
position: relative;
}
.activity-timeline::before {
content: '';
position: absolute;
left: 20px;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--light-color);
}
.timeline-item {
position: relative;
padding-left: 50px;
margin-bottom: 1.5rem;
}
.timeline-marker {
position: absolute;
left: 12px;
top: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--primary-color);
border: 3px solid #fff;
box-shadow: 0 0 0 2px var(--primary-color);
}
.timeline-content {
background-color: #fff;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
/* Engagement Indicators */
.engagement-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 0.5rem;
}
.engagement-high {
background-color: var(--success-color);
}
.engagement-medium {
background-color: var(--warning-color);
}
.engagement-low {
background-color: var(--danger-color);
}
/* Loading States */
.loading-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.card-body {
padding: 1rem;
}
.project-item {
margin-bottom: 0.5rem;
}
.display-4 {
font-size: 2rem;
}
}
/* Search and Filter */
.search-box {
border-radius: 50px;
border: 2px solid var(--light-color);
transition: border-color 0.3s ease;
}
.search-box:focus {
border-color: var(--primary-color);
box-shadow: none;
}
/* Custom Scrollbar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--secondary-color) var(--light-color);
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--light-color);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--secondary-color);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--dark-color);
}
/* Tool Icons */
.tool-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
margin-right: 0.5rem;
font-size: 0.875rem;
}
.tool-icon.tool-edit {
background-color: rgba(40, 167, 69, 0.1);
color: var(--success-color);
}
.tool-icon.tool-write {
background-color: rgba(0, 123, 255, 0.1);
color: var(--primary-color);
}
.tool-icon.tool-read {
background-color: rgba(23, 162, 184, 0.1);
color: var(--info-color);
}
.tool-icon.tool-bash {
background-color: rgba(108, 117, 125, 0.1);
color: var(--secondary-color);
}
/* Animation Classes */
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-in-right {
animation: slideInRight 0.5s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Footer */
footer {
margin-top: auto;
border-top: 1px solid rgba(0, 0, 0, 0.125);
}
/* Print Styles */
@media print {
.navbar,
.btn,
footer {
display: none !important;
}
.card {
box-shadow: none !important;
border: 1px solid #ddd !important;
}
body {
background-color: white !important;
}
}

View file

@ -0,0 +1,251 @@
/**
* API Client for Claude Code Project Tracker
*/
class ApiClient {
constructor(baseUrl = '') {
this.baseUrl = baseUrl;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}/api${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
try {
const response = await fetch(url, config);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`API request failed: ${endpoint}`, error);
throw error;
}
}
// Projects API
async getProjects(limit = 50, offset = 0) {
return this.request(`/projects?limit=${limit}&offset=${offset}`);
}
async getProject(projectId) {
return this.request(`/projects/${projectId}`);
}
async getProjectTimeline(projectId, startDate = null, endDate = null) {
let url = `/projects/${projectId}/timeline`;
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (params.toString()) {
url += `?${params.toString()}`;
}
return this.request(url);
}
async getProjectStats(projectId, days = 30) {
return this.request(`/projects/${projectId}/stats?days=${days}`);
}
// Analytics API
async getProductivityMetrics(projectId = null, days = 30) {
let url = `/analytics/productivity?days=${days}`;
if (projectId) {
url += `&project_id=${projectId}`;
}
return this.request(url);
}
async getDevelopmentPatterns(projectId = null, days = 30) {
let url = `/analytics/patterns?days=${days}`;
if (projectId) {
url += `&project_id=${projectId}`;
}
return this.request(url);
}
async getLearningInsights(projectId = null, days = 30) {
let url = `/analytics/learning?days=${days}`;
if (projectId) {
url += `&project_id=${projectId}`;
}
return this.request(url);
}
async getAnalyticsSummary(projectId = null) {
let url = '/analytics/summary';
if (projectId) {
url += `?project_id=${projectId}`;
}
return this.request(url);
}
// Conversations API
async searchConversations(query, projectId = null, limit = 20) {
let url = `/conversations/search?query=${encodeURIComponent(query)}&limit=${limit}`;
if (projectId) {
url += `&project_id=${projectId}`;
}
return this.request(url);
}
async getConversation(conversationId) {
return this.request(`/conversations/${conversationId}`);
}
// Activities API
async getActivities(sessionId = null, toolName = null, limit = 50, offset = 0) {
let url = `/activities?limit=${limit}&offset=${offset}`;
if (sessionId) url += `&session_id=${sessionId}`;
if (toolName) url += `&tool_name=${toolName}`;
return this.request(url);
}
async getToolUsageStats(sessionId = null, projectId = null, days = 30) {
let url = `/activities/stats/tools?days=${days}`;
if (sessionId) url += `&session_id=${sessionId}`;
if (projectId) url += `&project_id=${projectId}`;
return this.request(url);
}
async getLanguageUsageStats(projectId = null, days = 30) {
let url = `/activities/stats/languages?days=${days}`;
if (projectId) url += `&project_id=${projectId}`;
return this.request(url);
}
// Waiting Periods API
async getWaitingPeriods(sessionId = null, projectId = null, limit = 50, offset = 0) {
let url = `/waiting/periods?limit=${limit}&offset=${offset}`;
if (sessionId) url += `&session_id=${sessionId}`;
if (projectId) url += `&project_id=${projectId}`;
return this.request(url);
}
async getEngagementStats(sessionId = null, projectId = null, days = 7) {
let url = `/waiting/stats/engagement?days=${days}`;
if (sessionId) url += `&session_id=${sessionId}`;
if (projectId) url += `&project_id=${projectId}`;
return this.request(url);
}
// Git Operations API
async getGitOperations(sessionId = null, projectId = null, operation = null, limit = 50, offset = 0) {
let url = `/git/operations?limit=${limit}&offset=${offset}`;
if (sessionId) url += `&session_id=${sessionId}`;
if (projectId) url += `&project_id=${projectId}`;
if (operation) url += `&operation=${operation}`;
return this.request(url);
}
async getCommitStats(projectId = null, days = 30) {
let url = `/git/stats/commits?days=${days}`;
if (projectId) url += `&project_id=${projectId}`;
return this.request(url);
}
async getGitActivityStats(projectId = null, days = 30) {
let url = `/git/stats/activity?days=${days}`;
if (projectId) url += `&project_id=${projectId}`;
return this.request(url);
}
// Sessions API
async getSession(sessionId) {
return this.request(`/sessions/${sessionId}`);
}
// Health check
async healthCheck() {
return this.request('/../health');
}
}
// Create global API client instance
const apiClient = new ApiClient();
// Utility functions for common operations
const ApiUtils = {
formatDuration: (minutes) => {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
},
formatDate: (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
},
formatRelativeTime: (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
},
getToolIcon: (toolName) => {
const icons = {
'Edit': 'fas fa-edit',
'Write': 'fas fa-file-alt',
'Read': 'fas fa-eye',
'Bash': 'fas fa-terminal',
'Grep': 'fas fa-search',
'Glob': 'fas fa-folder-open',
'Task': 'fas fa-cogs',
'WebFetch': 'fas fa-globe'
};
return icons[toolName] || 'fas fa-tool';
},
getToolColor: (toolName) => {
const colors = {
'Edit': 'success',
'Write': 'primary',
'Read': 'info',
'Bash': 'secondary',
'Grep': 'warning',
'Glob': 'info',
'Task': 'dark',
'WebFetch': 'primary'
};
return colors[toolName] || 'secondary';
},
getEngagementBadge: (score) => {
if (score >= 0.8) return { class: 'success', text: 'High' };
if (score >= 0.5) return { class: 'warning', text: 'Medium' };
return { class: 'danger', text: 'Low' };
},
truncateText: (text, maxLength = 100) => {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
};

View file

@ -0,0 +1,327 @@
/**
* Dashboard JavaScript functionality
*/
let productivityChart = null;
let toolsChart = null;
async function loadDashboardData() {
try {
// Show loading state
showLoadingState();
// Load data in parallel
const [
analyticsSummary,
productivityMetrics,
toolUsageStats,
recentProjects
] = await Promise.all([
apiClient.getAnalyticsSummary(),
apiClient.getProductivityMetrics(null, 30),
apiClient.getToolUsageStats(null, null, 30),
apiClient.getProjects(5, 0)
]);
// Update summary cards
updateSummaryCards(analyticsSummary);
// Update engagement metrics
updateEngagementMetrics(productivityMetrics);
// Update charts
updateProductivityChart(productivityMetrics.productivity_trends);
updateToolsChart(toolUsageStats);
// Update recent projects
updateRecentProjects(recentProjects);
// Update quick stats
updateQuickStats(analyticsSummary);
} catch (error) {
console.error('Failed to load dashboard data:', error);
showErrorState('Failed to load dashboard data. Please try refreshing the page.');
}
}
function showLoadingState() {
// Update cards with loading state
document.getElementById('total-sessions').textContent = '-';
document.getElementById('total-time').textContent = '-';
document.getElementById('active-projects').textContent = '-';
document.getElementById('productivity-score').textContent = '-';
}
function showErrorState(message) {
// Show error message
const alertHtml = `
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container');
container.insertAdjacentHTML('afterbegin', alertHtml);
}
function updateSummaryCards(summary) {
const overview = summary.overview || {};
document.getElementById('total-sessions').textContent =
overview.total_sessions?.toLocaleString() || '0';
document.getElementById('total-time').textContent =
overview.total_time_hours?.toFixed(1) || '0';
document.getElementById('active-projects').textContent =
overview.projects_tracked?.toLocaleString() || '0';
document.getElementById('productivity-score').textContent =
overview.productivity_indicators?.productivity_score?.toFixed(0) || '0';
}
function updateEngagementMetrics(metrics) {
document.getElementById('engagement-score').textContent =
metrics.engagement_score?.toFixed(0) || '-';
document.getElementById('avg-session-length').textContent =
metrics.average_session_length?.toFixed(0) || '-';
document.getElementById('think-time').textContent =
metrics.think_time_average?.toFixed(0) || '-';
}
function updateProductivityChart(trendsData) {
const ctx = document.getElementById('productivityChart').getContext('2d');
// Destroy existing chart
if (productivityChart) {
productivityChart.destroy();
}
const labels = trendsData.map(item => {
const date = new Date(item.date);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
const data = trendsData.map(item => item.score);
productivityChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Productivity Score',
data: data,
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
},
x: {
ticks: {
maxTicksLimit: 7
}
}
}
}
});
}
function updateToolsChart(toolsData) {
const ctx = document.getElementById('toolsChart').getContext('2d');
// Destroy existing chart
if (toolsChart) {
toolsChart.destroy();
}
// Get top 5 tools
const topTools = toolsData.slice(0, 5);
const labels = topTools.map(item => item.tool_name);
const data = topTools.map(item => item.usage_count);
const colors = [
'#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545'
];
toolsChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors,
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
}
}
}
});
}
function updateRecentProjects(projects) {
const container = document.getElementById('recent-projects');
if (!projects.length) {
container.innerHTML = `
<div class="text-center text-muted py-4">
<i class="fas fa-folder-open fa-2x mb-2"></i>
<p>No projects found</p>
</div>
`;
return;
}
const projectsHtml = projects.map(project => `
<div class="project-item fade-in">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="project-title">${project.name}</div>
<div class="project-path">${project.path}</div>
<div class="project-stats mt-2">
<span class="me-3">
<i class="fas fa-clock me-1"></i>
${ApiUtils.formatDuration(project.total_time_minutes)}
</span>
<span class="me-3">
<i class="fas fa-play-circle me-1"></i>
${project.total_sessions} sessions
</span>
${project.languages ? `
<span>
<i class="fas fa-code me-1"></i>
${project.languages.slice(0, 2).join(', ')}
</span>
` : ''}
</div>
</div>
<small class="text-muted">
${ApiUtils.formatRelativeTime(project.last_activity)}
</small>
</div>
</div>
`).join('');
container.innerHTML = projectsHtml;
}
function updateQuickStats(summary) {
const container = document.getElementById('quick-stats');
const recent = summary.recent_activity || {};
const statsHtml = `
<div class="row g-3">
<div class="col-sm-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-calendar-week fa-2x text-primary"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">${recent.sessions_last_7_days || 0}</div>
<small class="text-muted">Sessions this week</small>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-clock fa-2x text-success"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">${(recent.time_last_7_days_hours || 0).toFixed(1)}h</div>
<small class="text-muted">Time this week</small>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-chart-line fa-2x text-info"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">${(recent.daily_average_last_week || 0).toFixed(1)}</div>
<small class="text-muted">Daily average</small>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-fire fa-2x text-warning"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">${summary.overview?.tracking_period_days || 0}</div>
<small class="text-muted">Days tracked</small>
</div>
</div>
</div>
</div>
`;
container.innerHTML = statsHtml;
}
function refreshDashboard() {
// Add visual feedback
const refreshBtn = document.querySelector('[onclick="refreshDashboard()"]');
const icon = refreshBtn.querySelector('i');
icon.classList.add('fa-spin');
refreshBtn.disabled = true;
loadDashboardData().finally(() => {
icon.classList.remove('fa-spin');
refreshBtn.disabled = false;
});
}
// Auto-refresh dashboard every 5 minutes
setInterval(() => {
loadDashboardData();
}, 5 * 60 * 1000);
// Handle window resize for charts
window.addEventListener('resize', () => {
if (productivityChart) {
productivityChart.resize();
}
if (toolsChart) {
toolsChart.resize();
}
});

View file

@ -0,0 +1,390 @@
{% extends "base.html" %}
{% block title %}Analytics - Claude Code Project Tracker{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="fas fa-chart-line me-2"></i>
Development Analytics
</h1>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary active" onclick="setTimePeriod(30)">
30 Days
</button>
<button type="button" class="btn btn-outline-primary" onclick="setTimePeriod(7)">
7 Days
</button>
<button type="button" class="btn btn-outline-primary" onclick="setTimePeriod(90)">
90 Days
</button>
</div>
</div>
</div>
</div>
<!-- Productivity Metrics -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-brain me-2"></i>
Engagement Analysis
</h6>
</div>
<div class="card-body text-center">
<div class="display-6 text-primary mb-2" id="engagement-metric">-</div>
<p class="text-muted">Engagement Score</p>
<canvas id="engagementChart" height="150"></canvas>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-clock me-2"></i>
Time Analysis
</h6>
</div>
<div class="card-body">
<div class="mb-3">
<div class="d-flex justify-content-between">
<span>Average Session</span>
<strong id="avg-session">-</strong>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between">
<span>Think Time</span>
<strong id="think-time-metric">-</strong>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between">
<span>Files per Session</span>
<strong id="files-per-session">-</strong>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-graduation-cap me-2"></i>
Learning Insights
</h6>
</div>
<div class="card-body" id="learning-insights">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Development Patterns -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-chart-area me-2"></i>
Development Patterns
</h6>
</div>
<div class="card-body">
<canvas id="patternsChart" height="100"></canvas>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-user-clock me-2"></i>
Working Hours
</h6>
</div>
<div class="card-body">
<canvas id="hoursChart" height="150"></canvas>
</div>
</div>
</div>
</div>
<!-- Tool Usage & Git Activity -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-tools me-2"></i>
Tool Usage Statistics
</h6>
</div>
<div class="card-body">
<div id="tool-stats">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fab fa-git-alt me-2"></i>
Git Activity
</h6>
</div>
<div class="card-body">
<div id="git-stats">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentTimePeriod = 30;
let engagementChart = null;
let patternsChart = null;
let hoursChart = null;
document.addEventListener('DOMContentLoaded', function() {
loadAnalyticsData();
});
function setTimePeriod(days) {
currentTimePeriod = days;
// Update active button
document.querySelectorAll('.btn-group .btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
loadAnalyticsData();
}
async function loadAnalyticsData() {
try {
const [
productivityMetrics,
developmentPatterns,
learningInsights,
toolUsageStats,
gitStats
] = await Promise.all([
apiClient.getProductivityMetrics(null, currentTimePeriod),
apiClient.getDevelopmentPatterns(null, currentTimePeriod),
apiClient.getLearningInsights(null, currentTimePeriod),
apiClient.getToolUsageStats(null, null, currentTimePeriod),
apiClient.getGitActivityStats(null, currentTimePeriod)
]);
updateProductivityMetrics(productivityMetrics);
updateDevelopmentPatterns(developmentPatterns);
updateLearningInsights(learningInsights);
updateToolStats(toolUsageStats);
updateGitStats(gitStats);
} catch (error) {
console.error('Failed to load analytics data:', error);
}
}
function updateProductivityMetrics(metrics) {
document.getElementById('engagement-metric').textContent =
metrics.engagement_score?.toFixed(0) || '-';
document.getElementById('avg-session').textContent =
ApiUtils.formatDuration(metrics.average_session_length || 0);
document.getElementById('think-time-metric').textContent =
`${(metrics.think_time_average || 0).toFixed(1)}s`;
document.getElementById('files-per-session').textContent =
(metrics.files_per_session || 0).toFixed(1);
}
function updateDevelopmentPatterns(patterns) {
if (patterns.working_hours) {
updateWorkingHoursChart(patterns.working_hours);
}
if (patterns.productivity_trends) {
updatePatternsChart(patterns);
}
}
function updateWorkingHoursChart(workingHours) {
const ctx = document.getElementById('hoursChart').getContext('2d');
if (hoursChart) {
hoursChart.destroy();
}
const hours = Array.from({length: 24}, (_, i) => i);
const data = hours.map(hour => workingHours.distribution[hour] || 0);
hoursChart = new Chart(ctx, {
type: 'bar',
data: {
labels: hours.map(h => `${h}:00`),
datasets: [{
data: data,
backgroundColor: '#007bff',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { maxTicksLimit: 8 }
},
y: {
beginAtZero: true
}
}
}
});
}
function updateLearningInsights(insights) {
const container = document.getElementById('learning-insights');
if (insights.message) {
container.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-info-circle mb-2"></i>
<p class="small">${insights.message}</p>
</div>
`;
return;
}
const topTopics = Object.entries(insights.learning_topics?.frequency || {})
.sort(([,a], [,b]) => b - a)
.slice(0, 3);
const insightsHtml = `
<div class="mb-3">
<small class="text-muted">Most Discussed Topics</small>
${topTopics.map(([topic, count]) => `
<div class="d-flex justify-content-between align-items-center mt-1">
<span class="small">${topic}</span>
<span class="badge bg-primary">${count}</span>
</div>
`).join('')}
</div>
<div class="text-center">
<div class="fw-bold text-success">${insights.insights?.diverse_learning || 0}</div>
<small class="text-muted">Topics Explored</small>
</div>
`;
container.innerHTML = insightsHtml;
}
function updateToolStats(toolStats) {
const container = document.getElementById('tool-stats');
if (!toolStats.length) {
container.innerHTML = `
<div class="text-center text-muted py-3">
<i class="fas fa-tools mb-2"></i>
<p class="small">No tool usage data</p>
</div>
`;
return;
}
const topTools = toolStats.slice(0, 5);
const maxUsage = Math.max(...topTools.map(t => t.usage_count));
const toolsHtml = topTools.map(tool => {
const percentage = (tool.usage_count / maxUsage) * 100;
return `
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small">
<i class="${ApiUtils.getToolIcon(tool.tool_name)} me-1"></i>
${tool.tool_name}
</span>
<span class="badge bg-${ApiUtils.getToolColor(tool.tool_name)}">${tool.usage_count}</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-${ApiUtils.getToolColor(tool.tool_name)}"
style="width: ${percentage}%"></div>
</div>
</div>
`;
}).join('');
container.innerHTML = toolsHtml;
}
function updateGitStats(gitStats) {
const container = document.getElementById('git-stats');
if (!gitStats.total_operations) {
container.innerHTML = `
<div class="text-center text-muted py-3">
<i class="fab fa-git-alt mb-2"></i>
<p class="small">No git activity data</p>
</div>
`;
return;
}
const statsHtml = `
<div class="row g-3 text-center">
<div class="col-6">
<div class="fw-bold text-primary">${gitStats.total_operations}</div>
<small class="text-muted">Operations</small>
</div>
<div class="col-6">
<div class="fw-bold text-success">${gitStats.success_rate || 0}%</div>
<small class="text-muted">Success Rate</small>
</div>
</div>
<hr>
<div class="mb-2">
<small class="text-muted">Operation Types</small>
</div>
${Object.entries(gitStats.operations_by_type || {}).map(([type, count]) => `
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small">${type}</span>
<span class="badge bg-secondary">${count}</span>
</div>
`).join('')}
`;
container.innerHTML = statsHtml;
}
</script>
{% endblock %}

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Claude Code Project Tracker{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/css/dashboard.css" rel="stylesheet">
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/dashboard">
<i class="fas fa-code-branch me-2"></i>
Claude Code Tracker
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/dashboard">
<i class="fas fa-tachometer-alt me-1"></i>
Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/dashboard/projects">
<i class="fas fa-folder-open me-1"></i>
Projects
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/dashboard/analytics">
<i class="fas fa-chart-line me-1"></i>
Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/dashboard/conversations">
<i class="fas fa-comments me-1"></i>
Conversations
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/docs" target="_blank">
<i class="fas fa-book me-1"></i>
API Docs
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="container mt-4">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-light mt-5 py-4">
<div class="container">
<div class="row">
<div class="col-md-8">
<p class="text-muted mb-0">
<i class="fas fa-info-circle me-1"></i>
Claude Code Project Tracker - Development Intelligence System
</p>
</div>
<div class="col-md-4 text-end">
<p class="text-muted mb-0">
<small>Version 1.0.0</small>
</p>
</div>
</div>
</div>
</footer>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/static/js/api-client.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}Conversations - Claude Code Project Tracker{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="fas fa-comments me-2"></i>
Conversation History
</h1>
</div>
</div>
</div>
<!-- Search Interface -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<div class="input-group">
<input type="text" class="form-control form-control-lg"
placeholder="Search conversations..." id="search-query"
onkeypress="handleSearchKeypress(event)">
<button class="btn btn-primary" type="button" onclick="searchConversations()">
<i class="fas fa-search me-1"></i>
Search
</button>
</div>
<div class="form-text">
Search through your conversation history with Claude Code
</div>
</div>
<div class="col-md-4">
<select class="form-select" id="project-filter">
<option value="">All Projects</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Search Results -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-history me-2"></i>
<span id="results-title">Recent Conversations</span>
</h6>
</div>
<div class="card-body">
<div id="conversation-results">
<div class="text-center text-muted py-5">
<i class="fas fa-search fa-3x mb-3"></i>
<h5>Search Your Conversations</h5>
<p>Enter a search term to find relevant conversations with Claude.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
loadProjects();
});
async function loadProjects() {
try {
const projects = await apiClient.getProjects();
const select = document.getElementById('project-filter');
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load projects:', error);
}
}
function handleSearchKeypress(event) {
if (event.key === 'Enter') {
searchConversations();
}
}
async function searchConversations() {
const query = document.getElementById('search-query').value.trim();
const projectId = document.getElementById('project-filter').value || null;
const resultsContainer = document.getElementById('conversation-results');
const resultsTitle = document.getElementById('results-title');
if (!query) {
resultsContainer.innerHTML = `
<div class="text-center text-warning py-4">
<i class="fas fa-exclamation-circle fa-2x mb-2"></i>
<p>Please enter a search term</p>
</div>
`;
return;
}
// Show loading state
resultsContainer.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Searching...</span>
</div>
<p class="mt-2 text-muted">Searching conversations...</p>
</div>
`;
resultsTitle.textContent = `Search Results for "${query}"`;
try {
const results = await apiClient.searchConversations(query, projectId, 20);
displaySearchResults(results, query);
} catch (error) {
console.error('Search failed:', error);
resultsContainer.innerHTML = `
<div class="text-center text-danger py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
<p>Search failed. Please try again.</p>
</div>
`;
}
}
function displaySearchResults(results, query) {
const container = document.getElementById('conversation-results');
if (!results.length) {
container.innerHTML = `
<div class="text-center text-muted py-5">
<i class="fas fa-search fa-3x mb-3"></i>
<h5>No Results Found</h5>
<p>No conversations match your search term: "${query}"</p>
<p class="small text-muted">Try using different keywords or check your spelling.</p>
</div>
`;
return;
}
const resultsHtml = results.map(result => `
<div class="conversation-result border-bottom py-3 fade-in">
<div class="row">
<div class="col-md-8">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">${result.project_name}</span>
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
${ApiUtils.formatRelativeTime(result.timestamp)}
</small>
<span class="badge bg-success ms-2">
${(result.relevance_score * 100).toFixed(0)}% match
</span>
</div>
${result.user_prompt ? `
<div class="mb-2">
<strong class="text-primary">
<i class="fas fa-user me-1"></i>
You:
</strong>
<p class="mb-1">${highlightSearchTerms(ApiUtils.truncateText(result.user_prompt, 200), query)}</p>
</div>
` : ''}
${result.claude_response ? `
<div class="mb-2">
<strong class="text-success">
<i class="fas fa-robot me-1"></i>
Claude:
</strong>
<p class="mb-1">${highlightSearchTerms(ApiUtils.truncateText(result.claude_response, 200), query)}</p>
</div>
` : ''}
${result.context && result.context.length ? `
<div class="mt-2">
<small class="text-muted">Context snippets:</small>
${result.context.map(snippet => `
<div class="bg-light p-2 rounded mt-1">
<small>${highlightSearchTerms(snippet, query)}</small>
</div>
`).join('')}
</div>
` : ''}
</div>
<div class="col-md-4 text-end">
<button class="btn btn-outline-primary btn-sm" onclick="viewFullConversation(${result.id})">
<i class="fas fa-eye me-1"></i>
View Full
</button>
</div>
</div>
</div>
`).join('');
container.innerHTML = resultsHtml;
}
function highlightSearchTerms(text, query) {
if (!text || !query) return text;
const terms = query.toLowerCase().split(' ');
let highlightedText = text;
terms.forEach(term => {
const regex = new RegExp(`(${term})`, 'gi');
highlightedText = highlightedText.replace(regex, '<mark>$1</mark>');
});
return highlightedText;
}
async function viewFullConversation(conversationId) {
try {
const conversation = await apiClient.getConversation(conversationId);
showConversationModal(conversation);
} catch (error) {
console.error('Failed to load conversation:', error);
alert('Failed to load full conversation');
}
}
function showConversationModal(conversation) {
const modalHtml = `
<div class="modal fade" id="conversationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-comments me-2"></i>
Conversation Details
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="row">
<div class="col-md-6">
<strong>Project:</strong> ${conversation.project_name}
</div>
<div class="col-md-6">
<strong>Date:</strong> ${ApiUtils.formatDate(conversation.timestamp)}
</div>
</div>
${conversation.tools_used && conversation.tools_used.length ? `
<div class="mt-2">
<strong>Tools Used:</strong>
${conversation.tools_used.map(tool => `
<span class="badge bg-${ApiUtils.getToolColor(tool)} me-1">${tool}</span>
`).join('')}
</div>
` : ''}
</div>
${conversation.user_prompt ? `
<div class="mb-4">
<div class="card">
<div class="card-header bg-primary text-white">
<i class="fas fa-user me-1"></i>
Your Question
</div>
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">${conversation.user_prompt}</p>
</div>
</div>
</div>
` : ''}
${conversation.claude_response ? `
<div class="mb-4">
<div class="card">
<div class="card-header bg-success text-white">
<i class="fas fa-robot me-1"></i>
Claude's Response
</div>
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">${conversation.claude_response}</p>
</div>
</div>
</div>
` : ''}
${conversation.files_affected && conversation.files_affected.length ? `
<div class="mb-3">
<strong>Files Affected:</strong>
<ul class="list-unstyled mt-2">
${conversation.files_affected.map(file => `
<li><code>${file}</code></li>
`).join('')}
</ul>
</div>
` : ''}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove any existing modal
const existingModal = document.getElementById('conversationModal');
if (existingModal) {
existingModal.remove();
}
// Add new modal to body
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('conversationModal'));
modal.show();
}
</script>
{% endblock %}

View file

@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}Dashboard - Claude Code Project Tracker{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="fas fa-tachometer-alt me-2"></i>
Development Dashboard
</h1>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary" onclick="refreshDashboard()">
<i class="fas fa-sync-alt me-1"></i>
Refresh
</button>
</div>
</div>
</div>
</div>
<!-- Status Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title mb-0">Total Sessions</h5>
<h2 class="mb-0" id="total-sessions">-</h2>
</div>
<i class="fas fa-play-circle fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title mb-0">Development Time</h5>
<h2 class="mb-0" id="total-time">-</h2>
<small class="opacity-75">hours</small>
</div>
<i class="fas fa-clock fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title mb-0">Active Projects</h5>
<h2 class="mb-0" id="active-projects">-</h2>
</div>
<i class="fas fa-folder-open fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title mb-0">Productivity Score</h5>
<h2 class="mb-0" id="productivity-score">-</h2>
<small class="opacity-75">out of 100</small>
</div>
<i class="fas fa-chart-line fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-area me-2"></i>
Productivity Trends (Last 30 Days)
</h5>
</div>
<div class="card-body">
<canvas id="productivityChart" height="100"></canvas>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-tools me-2"></i>
Top Tools Used
</h5>
</div>
<div class="card-body">
<canvas id="toolsChart" height="200"></canvas>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Recent Projects
</h5>
</div>
<div class="card-body">
<div id="recent-projects">
<div class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-bolt me-2"></i>
Quick Stats
</h5>
<small class="text-muted">Last 7 days</small>
</div>
<div class="card-body">
<div id="quick-stats">
<div class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Engagement Insights -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-brain me-2"></i>
Engagement & Flow Analysis
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="text-center">
<div class="display-4 text-primary mb-2" id="engagement-score">-</div>
<h6>Engagement Score</h6>
<p class="text-muted small">Based on think times and response patterns</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="display-4 text-success mb-2" id="avg-session-length">-</div>
<h6>Avg Session Length</h6>
<p class="text-muted small">minutes per development session</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="display-4 text-info mb-2" id="think-time">-</div>
<h6>Think Time</h6>
<p class="text-muted small">average seconds between interactions</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js"></script>
<script>
// Initialize dashboard on page load
document.addEventListener('DOMContentLoaded', function() {
loadDashboardData();
});
</script>
{% endblock %}

View file

@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block title %}Projects - Claude Code Project Tracker{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="fas fa-folder-open me-2"></i>
Projects Overview
</h1>
<div class="d-flex gap-2">
<div class="input-group" style="width: 300px;">
<input type="text" class="form-control search-box" placeholder="Search projects..." id="search-input">
<button class="btn btn-outline-secondary" type="button">
<i class="fas fa-search"></i>
</button>
</div>
<button type="button" class="btn btn-outline-primary" onclick="refreshProjects()">
<i class="fas fa-sync-alt me-1"></i>
Refresh
</button>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div id="projects-list">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading projects...</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
loadProjects();
// Search functionality
document.getElementById('search-input').addEventListener('input', function(e) {
filterProjects(e.target.value);
});
});
async function loadProjects() {
try {
const projects = await apiClient.getProjects();
displayProjects(projects);
} catch (error) {
console.error('Failed to load projects:', error);
document.getElementById('projects-list').innerHTML = `
<div class="text-center text-danger py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
<p>Failed to load projects</p>
</div>
`;
}
}
function displayProjects(projects) {
const container = document.getElementById('projects-list');
if (!projects.length) {
container.innerHTML = `
<div class="text-center text-muted py-4">
<i class="fas fa-folder-plus fa-3x mb-3"></i>
<h5>No projects found</h5>
<p>Start using Claude Code in a project directory to begin tracking.</p>
</div>
`;
return;
}
const projectsHtml = projects.map(project => `
<div class="project-card card mb-3 fade-in" data-name="${project.name.toLowerCase()}" data-path="${project.path.toLowerCase()}">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h5 class="card-title mb-2">
<i class="fas fa-folder me-2 text-primary"></i>
${project.name}
</h5>
<p class="text-muted mb-2">
<i class="fas fa-map-marker-alt me-1"></i>
<code>${project.path}</code>
</p>
${project.git_repo ? `
<p class="text-muted mb-2">
<i class="fab fa-git-alt me-1"></i>
<a href="${project.git_repo}" target="_blank" class="text-decoration-none">${project.git_repo}</a>
</p>
` : ''}
${project.languages && project.languages.length ? `
<div class="mb-2">
${project.languages.map(lang => `
<span class="badge bg-secondary me-1">${lang}</span>
`).join('')}
</div>
` : ''}
</div>
<div class="col-md-4 text-end">
<div class="row g-2 text-center">
<div class="col-6">
<div class="fw-bold text-primary">${project.total_sessions}</div>
<small class="text-muted">Sessions</small>
</div>
<div class="col-6">
<div class="fw-bold text-success">${ApiUtils.formatDuration(project.total_time_minutes)}</div>
<small class="text-muted">Time</small>
</div>
<div class="col-6">
<div class="fw-bold text-info">${project.files_modified_count}</div>
<small class="text-muted">Files</small>
</div>
<div class="col-6">
<div class="fw-bold text-warning">${project.lines_changed_count.toLocaleString()}</div>
<small class="text-muted">Lines</small>
</div>
</div>
<hr class="my-2">
<small class="text-muted">
Last active: ${ApiUtils.formatRelativeTime(project.last_activity)}
</small>
<div class="mt-2">
<button class="btn btn-outline-primary btn-sm me-1" onclick="viewProjectTimeline(${project.id})">
<i class="fas fa-timeline me-1"></i>
Timeline
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="viewProjectStats(${project.id})">
<i class="fas fa-chart-bar me-1"></i>
Stats
</button>
</div>
</div>
</div>
</div>
</div>
`).join('');
container.innerHTML = projectsHtml;
}
function filterProjects(searchTerm) {
const projects = document.querySelectorAll('.project-card');
const term = searchTerm.toLowerCase();
projects.forEach(project => {
const name = project.dataset.name;
const path = project.dataset.path;
const matches = name.includes(term) || path.includes(term);
project.style.display = matches ? 'block' : 'none';
});
}
function refreshProjects() {
const refreshBtn = document.querySelector('[onclick="refreshProjects()"]');
const icon = refreshBtn.querySelector('i');
icon.classList.add('fa-spin');
refreshBtn.disabled = true;
loadProjects().finally(() => {
icon.classList.remove('fa-spin');
refreshBtn.disabled = false;
});
}
function viewProjectTimeline(projectId) {
// Could open a modal or navigate to a detailed timeline view
window.open(`/dashboard/projects/${projectId}/timeline`, '_blank');
}
function viewProjectStats(projectId) {
// Could open a modal or navigate to detailed stats
window.open(`/dashboard/projects/${projectId}/stats`, '_blank');
}
</script>
{% endblock %}

7
app/database/__init__.py Normal file
View file

@ -0,0 +1,7 @@
"""
Database connection and initialization for Claude Code Project Tracker.
"""
from .connection import get_db, get_engine, init_database
__all__ = ["get_db", "get_engine", "init_database"]

View file

@ -0,0 +1,71 @@
"""
Database connection management for the Claude Code Project Tracker.
"""
import os
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import StaticPool
from app.models.base import Base
# Database configuration
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/tracker.db")
# Create async engine
engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("DEBUG", "false").lower() == "true", # Log SQL queries in debug mode
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {},
poolclass=StaticPool if "sqlite" in DATABASE_URL else None,
)
# Create session factory
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency function to get database session.
Used by FastAPI dependency injection to provide database sessions
to route handlers.
"""
async with async_session_maker() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
def get_engine():
"""Get the database engine."""
return engine
async def init_database():
"""Initialize the database by creating all tables."""
async with engine.begin() as conn:
# Import all models to ensure they're registered
from app.models import (
Project, Session, Conversation, Activity,
WaitingPeriod, GitOperation
)
# Create all tables
await conn.run_sync(Base.metadata.create_all)
print("Database initialized successfully!")
async def close_database():
"""Close database connections."""
await engine.dispose()
print("Database connections closed.")

24
app/database/init_db.py Normal file
View file

@ -0,0 +1,24 @@
"""
Database initialization script.
"""
import asyncio
import os
from pathlib import Path
from app.database.connection import init_database
async def main():
"""Initialize the database."""
# Ensure data directory exists
data_dir = Path("data")
data_dir.mkdir(exist_ok=True)
print("Initializing Claude Code Project Tracker database...")
await init_database()
print("Database initialization complete!")
if __name__ == "__main__":
asyncio.run(main())

21
app/models/__init__.py Normal file
View file

@ -0,0 +1,21 @@
"""
Database models for the Claude Code Project Tracker.
"""
from .base import Base
from .project import Project
from .session import Session
from .conversation import Conversation
from .activity import Activity
from .waiting_period import WaitingPeriod
from .git_operation import GitOperation
__all__ = [
"Base",
"Project",
"Session",
"Conversation",
"Activity",
"WaitingPeriod",
"GitOperation",
]

162
app/models/activity.py Normal file
View file

@ -0,0 +1,162 @@
"""
Activity model for tracking tool usage and file operations.
"""
from datetime import datetime
from typing import Optional, Dict, Any
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base, TimestampMixin
class Activity(Base, TimestampMixin):
"""
Represents a single tool usage or file operation during development.
Activities are generated by Claude Code tool usage and provide
detailed insight into the development workflow.
"""
__tablename__ = "activities"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True)
# Foreign keys
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
conversation_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("conversations.id"),
nullable=True,
index=True
)
# Activity timing
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=func.now(),
index=True
)
# Activity details
tool_name: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
file_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True, index=True)
# Metadata and results
metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Code change metrics (for Edit/Write operations)
lines_added: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
lines_removed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Relationships
session: Mapped["Session"] = relationship("Session", back_populates="activities")
conversation: Mapped[Optional["Conversation"]] = relationship(
"Conversation",
foreign_keys=[conversation_id]
)
def __repr__(self) -> str:
file_info = f", file='{self.file_path}'" if self.file_path else ""
return f"<Activity(id={self.id}, tool='{self.tool_name}', action='{self.action}'{file_info})>"
@property
def is_file_operation(self) -> bool:
"""Check if this activity involves file operations."""
return self.tool_name in {"Edit", "Write", "Read"}
@property
def is_code_execution(self) -> bool:
"""Check if this activity involves code/command execution."""
return self.tool_name in {"Bash", "Task"}
@property
def is_search_operation(self) -> bool:
"""Check if this activity involves searching."""
return self.tool_name in {"Grep", "Glob"}
@property
def total_lines_changed(self) -> int:
"""Get total lines changed (added + removed)."""
added = self.lines_added or 0
removed = self.lines_removed or 0
return added + removed
@property
def net_lines_changed(self) -> int:
"""Get net lines changed (added - removed)."""
added = self.lines_added or 0
removed = self.lines_removed or 0
return added - removed
def get_file_extension(self) -> Optional[str]:
"""Extract file extension from file path."""
if not self.file_path:
return None
if "." in self.file_path:
return self.file_path.split(".")[-1].lower()
return None
def get_programming_language(self) -> Optional[str]:
"""Infer programming language from file extension."""
ext = self.get_file_extension()
if not ext:
return None
language_map = {
"py": "python",
"js": "javascript",
"ts": "typescript",
"jsx": "javascript",
"tsx": "typescript",
"go": "go",
"rs": "rust",
"java": "java",
"cpp": "cpp",
"c": "c",
"h": "c",
"hpp": "cpp",
"rb": "ruby",
"php": "php",
"html": "html",
"css": "css",
"scss": "scss",
"sql": "sql",
"md": "markdown",
"yml": "yaml",
"yaml": "yaml",
"json": "json",
"xml": "xml",
"sh": "shell",
"bash": "shell",
}
return language_map.get(ext)
def is_successful(self) -> bool:
"""Check if the activity completed successfully."""
return self.success and not self.error_message
def get_command_executed(self) -> Optional[str]:
"""Get the command that was executed (for Bash activities)."""
if self.tool_name == "Bash" and self.metadata:
return self.metadata.get("command")
return None
def get_search_pattern(self) -> Optional[str]:
"""Get the search pattern (for Grep activities)."""
if self.tool_name == "Grep" and self.metadata:
return self.metadata.get("pattern")
return None
def get_task_type(self) -> Optional[str]:
"""Get the task type (for Task activities)."""
if self.tool_name == "Task" and self.metadata:
return self.metadata.get("task_type")
return None

32
app/models/base.py Normal file
View file

@ -0,0 +1,32 @@
"""
Base model configuration for SQLAlchemy models.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
class TimestampMixin:
"""Mixin to add created_at and updated_at timestamps to models."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False
)

118
app/models/conversation.py Normal file
View file

@ -0,0 +1,118 @@
"""
Conversation model for tracking dialogue between user and Claude.
"""
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base, TimestampMixin
class Conversation(Base, TimestampMixin):
"""
Represents a conversation exchange between user and Claude.
Each conversation entry captures either a user prompt or Claude's response,
along with context about tools used and files affected.
"""
__tablename__ = "conversations"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True)
# Foreign key to session
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
# Timing
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=func.now(),
index=True
)
# Conversation content
user_prompt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
claude_response: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Context and metadata
tools_used: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
files_affected: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
context: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
# Token estimates for analysis
tokens_input: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tokens_output: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Exchange type for categorization
exchange_type: Mapped[str] = mapped_column(String(50), nullable=False) # user_prompt, claude_response
# Relationships
session: Mapped["Session"] = relationship("Session", back_populates="conversations")
def __repr__(self) -> str:
content_preview = ""
if self.user_prompt:
content_preview = self.user_prompt[:50] + "..." if len(self.user_prompt) > 50 else self.user_prompt
elif self.claude_response:
content_preview = self.claude_response[:50] + "..." if len(self.claude_response) > 50 else self.claude_response
return f"<Conversation(id={self.id}, type='{self.exchange_type}', content='{content_preview}')>"
@property
def is_user_prompt(self) -> bool:
"""Check if this is a user prompt."""
return self.exchange_type == "user_prompt"
@property
def is_claude_response(self) -> bool:
"""Check if this is a Claude response."""
return self.exchange_type == "claude_response"
@property
def content_length(self) -> int:
"""Get the total character length of the conversation content."""
user_length = len(self.user_prompt) if self.user_prompt else 0
claude_length = len(self.claude_response) if self.claude_response else 0
return user_length + claude_length
@property
def estimated_tokens(self) -> int:
"""Estimate total tokens in this conversation exchange."""
if self.tokens_input and self.tokens_output:
return self.tokens_input + self.tokens_output
# Rough estimation: ~4 characters per token
return self.content_length // 4
def get_intent_category(self) -> Optional[str]:
"""Extract intent category from context if available."""
if self.context and "intent" in self.context:
return self.context["intent"]
return None
def get_complexity_level(self) -> Optional[str]:
"""Extract complexity level from context if available."""
if self.context and "complexity" in self.context:
return self.context["complexity"]
return None
def has_file_operations(self) -> bool:
"""Check if this conversation involved file operations."""
if not self.tools_used:
return False
file_tools = {"Edit", "Write", "Read"}
return any(tool in file_tools for tool in self.tools_used)
def has_code_execution(self) -> bool:
"""Check if this conversation involved code execution."""
if not self.tools_used:
return False
execution_tools = {"Bash", "Task"}
return any(tool in execution_tools for tool in self.tools_used)

202
app/models/git_operation.py Normal file
View file

@ -0,0 +1,202 @@
"""
Git operation model for tracking repository changes.
"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base, TimestampMixin
class GitOperation(Base, TimestampMixin):
"""
Represents a git operation performed during a development session.
Tracks commits, pushes, pulls, branch operations, and other git commands
to provide insight into version control workflow.
"""
__tablename__ = "git_operations"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True)
# Foreign key to session
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
# Operation timing
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=func.now(),
index=True
)
# Operation details
operation: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # commit, push, pull, branch, etc.
command: Mapped[str] = mapped_column(Text, nullable=False)
result: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# File and change tracking
files_changed: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
lines_added: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
lines_removed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Commit-specific fields
commit_hash: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
# Branch operation fields
branch_from: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
branch_to: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Relationships
session: Mapped["Session"] = relationship("Session", back_populates="git_operations")
def __repr__(self) -> str:
return f"<GitOperation(id={self.id}, operation='{self.operation}', success={self.success})>"
@property
def is_commit(self) -> bool:
"""Check if this is a commit operation."""
return self.operation == "commit"
@property
def is_push(self) -> bool:
"""Check if this is a push operation."""
return self.operation == "push"
@property
def is_pull(self) -> bool:
"""Check if this is a pull operation."""
return self.operation == "pull"
@property
def is_branch_operation(self) -> bool:
"""Check if this is a branch-related operation."""
return self.operation in {"branch", "checkout", "merge", "rebase"}
@property
def total_lines_changed(self) -> int:
"""Get total lines changed (added + removed)."""
added = self.lines_added or 0
removed = self.lines_removed or 0
return added + removed
@property
def net_lines_changed(self) -> int:
"""Get net lines changed (added - removed)."""
added = self.lines_added or 0
removed = self.lines_removed or 0
return added - removed
@property
def files_count(self) -> int:
"""Get number of files changed."""
return len(self.files_changed) if self.files_changed else 0
def get_commit_message(self) -> Optional[str]:
"""Extract commit message from the command."""
if not self.is_commit:
return None
command = self.command
if "-m" in command:
# Extract message between quotes after -m
parts = command.split("-m")
if len(parts) > 1:
message_part = parts[1].strip()
# Remove quotes
if message_part.startswith('"') and message_part.endswith('"'):
return message_part[1:-1]
elif message_part.startswith("'") and message_part.endswith("'"):
return message_part[1:-1]
else:
# Find first quoted string
import re
match = re.search(r'["\']([^"\']*)["\']', message_part)
if match:
return match.group(1)
return None
def get_branch_name(self) -> Optional[str]:
"""Get branch name for branch operations."""
if self.branch_to:
return self.branch_to
elif self.branch_from:
return self.branch_from
# Try to extract from command
if "checkout" in self.command:
parts = self.command.split()
if len(parts) > 2:
return parts[-1] # Last argument is usually the branch
return None
def is_merge_commit(self) -> bool:
"""Check if this is a merge commit."""
commit_msg = self.get_commit_message()
return commit_msg is not None and "merge" in commit_msg.lower()
def is_feature_commit(self) -> bool:
"""Check if this appears to be a feature commit."""
commit_msg = self.get_commit_message()
if not commit_msg:
return False
feature_keywords = ["add", "implement", "create", "new", "feature"]
return any(keyword in commit_msg.lower() for keyword in feature_keywords)
def is_bugfix_commit(self) -> bool:
"""Check if this appears to be a bugfix commit."""
commit_msg = self.get_commit_message()
if not commit_msg:
return False
bugfix_keywords = ["fix", "bug", "resolve", "correct", "patch"]
return any(keyword in commit_msg.lower() for keyword in bugfix_keywords)
def is_refactor_commit(self) -> bool:
"""Check if this appears to be a refactoring commit."""
commit_msg = self.get_commit_message()
if not commit_msg:
return False
refactor_keywords = ["refactor", "cleanup", "improve", "optimize", "reorganize"]
return any(keyword in commit_msg.lower() for keyword in refactor_keywords)
def get_commit_category(self) -> str:
"""Categorize the commit based on its message."""
if not self.is_commit:
return "non-commit"
if self.is_merge_commit():
return "merge"
elif self.is_feature_commit():
return "feature"
elif self.is_bugfix_commit():
return "bugfix"
elif self.is_refactor_commit():
return "refactor"
else:
return "other"
def get_change_size_category(self) -> str:
"""Categorize the size of changes in this operation."""
total_changes = self.total_lines_changed
if total_changes == 0:
return "no-changes"
elif total_changes < 10:
return "small"
elif total_changes < 50:
return "medium"
elif total_changes < 200:
return "large"
else:
return "very-large"

69
app/models/project.py Normal file
View file

@ -0,0 +1,69 @@
"""
Project model for tracking development projects.
"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Text, Integer, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base, TimestampMixin
class Project(Base, TimestampMixin):
"""
Represents a development project tracked by Claude Code.
A project is typically identified by its filesystem path and may
correspond to a git repository.
"""
__tablename__ = "projects"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True)
# Core project information
name: Mapped[str] = mapped_column(String(255), nullable=False)
path: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True)
git_repo: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
languages: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
# Activity tracking
last_session: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
total_sessions: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
total_time_minutes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
files_modified_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
lines_changed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Relationships
sessions: Mapped[List["Session"]] = relationship(
"Session",
back_populates="project",
cascade="all, delete-orphan",
order_by="Session.start_time.desc()"
)
def __repr__(self) -> str:
return f"<Project(id={self.id}, name='{self.name}', path='{self.path}')>"
@property
def is_git_repo(self) -> bool:
"""Check if this project is a git repository."""
return self.git_repo is not None and self.git_repo != ""
@property
def primary_language(self) -> Optional[str]:
"""Get the primary programming language for this project."""
if self.languages and len(self.languages) > 0:
return self.languages[0]
return None
def update_stats(self, session_duration_minutes: int, files_count: int, lines_count: int) -> None:
"""Update project statistics after a session."""
self.total_sessions += 1
self.total_time_minutes += session_duration_minutes
self.files_modified_count += files_count
self.lines_changed_count += lines_count
self.last_session = func.now()

134
app/models/session.py Normal file
View file

@ -0,0 +1,134 @@
"""
Session model for tracking individual development sessions.
"""
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base, TimestampMixin
class Session(Base, TimestampMixin):
"""
Represents an individual development session within a project.
A session starts when Claude Code is launched or resumed and ends
when the user stops or the session is interrupted.
"""
__tablename__ = "sessions"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True)
# Foreign key to project
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
# Session timing
start_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=func.now(),
index=True
)
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Session metadata
session_type: Mapped[str] = mapped_column(String(50), nullable=False) # startup, resume, clear
working_directory: Mapped[str] = mapped_column(Text, nullable=False)
git_branch: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
environment: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
# Session statistics (updated as session progresses)
duration_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
activity_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
conversation_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
files_touched: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
# Relationships
project: Mapped["Project"] = relationship("Project", back_populates="sessions")
conversations: Mapped[List["Conversation"]] = relationship(
"Conversation",
back_populates="session",
cascade="all, delete-orphan",
order_by="Conversation.timestamp"
)
activities: Mapped[List["Activity"]] = relationship(
"Activity",
back_populates="session",
cascade="all, delete-orphan",
order_by="Activity.timestamp"
)
waiting_periods: Mapped[List["WaitingPeriod"]] = relationship(
"WaitingPeriod",
back_populates="session",
cascade="all, delete-orphan",
order_by="WaitingPeriod.start_time"
)
git_operations: Mapped[List["GitOperation"]] = relationship(
"GitOperation",
back_populates="session",
cascade="all, delete-orphan",
order_by="GitOperation.timestamp"
)
def __repr__(self) -> str:
return f"<Session(id={self.id}, project_id={self.project_id}, type='{self.session_type}')>"
@property
def is_active(self) -> bool:
"""Check if this session is still active (not ended)."""
return self.end_time is None
@property
def calculated_duration_minutes(self) -> Optional[int]:
"""Calculate session duration in minutes."""
if self.end_time is None:
# Session is still active, calculate current duration
current_duration = datetime.utcnow() - self.start_time
return int(current_duration.total_seconds() / 60)
else:
# Session is finished
if self.duration_minutes is not None:
return self.duration_minutes
else:
duration = self.end_time - self.start_time
return int(duration.total_seconds() / 60)
def end_session(self, end_reason: str = "normal") -> None:
"""End the session and calculate final statistics."""
if self.end_time is None:
self.end_time = func.now()
self.duration_minutes = self.calculated_duration_minutes
# Update project statistics
if self.project:
unique_files = len(set(self.files_touched or []))
total_lines = sum(
(activity.lines_added or 0) + (activity.lines_removed or 0)
for activity in self.activities
)
self.project.update_stats(
session_duration_minutes=self.duration_minutes or 0,
files_count=unique_files,
lines_count=total_lines
)
def add_activity(self) -> None:
"""Increment activity counter."""
self.activity_count += 1
def add_conversation(self) -> None:
"""Increment conversation counter."""
self.conversation_count += 1
def add_file_touched(self, file_path: str) -> None:
"""Add a file to the list of files touched in this session."""
if self.files_touched is None:
self.files_touched = []
if file_path not in self.files_touched:
self.files_touched.append(file_path)

View file

@ -0,0 +1,156 @@
"""
Waiting period model for tracking think time and engagement.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Text, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base, TimestampMixin
class WaitingPeriod(Base, TimestampMixin):
"""
Represents a period when Claude is waiting for user input.
These periods provide insight into user thinking time, engagement patterns,
and workflow interruptions during development sessions.
"""
__tablename__ = "waiting_periods"
# Primary key
id: Mapped[int] = mapped_column(primary_key=True)
# Foreign key to session
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
# Timing
start_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=func.now(),
index=True
)
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
duration_seconds: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
# Context
context_before: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
context_after: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Activity inference
likely_activity: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # thinking, research, external_work, break
# Relationships
session: Mapped["Session"] = relationship("Session", back_populates="waiting_periods")
def __repr__(self) -> str:
duration_info = f", duration={self.duration_seconds}s" if self.duration_seconds else ""
return f"<WaitingPeriod(id={self.id}, session_id={self.session_id}{duration_info})>"
@property
def is_active(self) -> bool:
"""Check if this waiting period is still active (not ended)."""
return self.end_time is None
@property
def calculated_duration_seconds(self) -> Optional[int]:
"""Calculate waiting period duration in seconds."""
if self.end_time is None:
# Still waiting, calculate current duration
current_duration = datetime.utcnow() - self.start_time
return int(current_duration.total_seconds())
else:
# Finished waiting
if self.duration_seconds is not None:
return self.duration_seconds
else:
duration = self.end_time - self.start_time
return int(duration.total_seconds())
@property
def duration_minutes(self) -> Optional[float]:
"""Get duration in minutes."""
seconds = self.calculated_duration_seconds
return seconds / 60 if seconds is not None else None
def end_waiting(self, context_after: Optional[str] = None) -> None:
"""End the waiting period and calculate duration."""
if self.end_time is None:
self.end_time = func.now()
self.duration_seconds = self.calculated_duration_seconds
if context_after:
self.context_after = context_after
def classify_activity(self) -> str:
"""
Classify the likely activity based on duration and context.
Returns one of: 'thinking', 'research', 'external_work', 'break'
"""
if self.likely_activity:
return self.likely_activity
duration = self.calculated_duration_seconds
if duration is None:
return "unknown"
# Classification based on duration
if duration < 10:
return "thinking" # Quick pause
elif duration < 60:
return "thinking" # Short contemplation
elif duration < 300: # 5 minutes
return "research" # Looking something up
elif duration < 1800: # 30 minutes
return "external_work" # Working on something else
else:
return "break" # Extended break
@property
def engagement_score(self) -> float:
"""
Calculate an engagement score based on waiting time.
Returns a score from 0.0 (disengaged) to 1.0 (highly engaged).
"""
duration = self.calculated_duration_seconds
if duration is None:
return 0.5 # Default neutral score
# Short waits indicate high engagement
if duration <= 5:
return 1.0
elif duration <= 30:
return 0.9
elif duration <= 120: # 2 minutes
return 0.7
elif duration <= 300: # 5 minutes
return 0.5
elif duration <= 900: # 15 minutes
return 0.3
else:
return 0.1 # Long waits indicate low engagement
def is_quick_response(self) -> bool:
"""Check if user responded quickly (< 30 seconds)."""
duration = self.calculated_duration_seconds
return duration is not None and duration < 30
def is_thoughtful_pause(self) -> bool:
"""Check if this was a thoughtful pause (30s - 2 minutes)."""
duration = self.calculated_duration_seconds
return duration is not None and 30 <= duration < 120
def is_research_break(self) -> bool:
"""Check if this was likely a research break (2 - 15 minutes)."""
duration = self.calculated_duration_seconds
return duration is not None and 120 <= duration < 900
def is_extended_break(self) -> bool:
"""Check if this was an extended break (> 15 minutes)."""
duration = self.calculated_duration_seconds
return duration is not None and duration >= 900