Ryan Malloy 44ed9936b7 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>
2025-08-11 02:59:21 -06:00

285 lines
10 KiB
Python

"""
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)}"
)