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