Compare commits
No commits in common. "166247bf70b9902f6ff460132f1d7152ee1409d1" and "1e0607b20c19f0a31430503ddbf769b3fbc519e4" have entirely different histories.
166247bf70
...
1e0607b20c
19
.env.example
19
.env.example
@ -1,19 +0,0 @@
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/tracker.db
|
||||
|
||||
# API Configuration
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
DEBUG=true
|
||||
|
||||
# Security (generate with: openssl rand -hex 32)
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Analytics
|
||||
ENABLE_ANALYTICS=true
|
||||
ANALYTICS_BATCH_SIZE=1000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=tracker.log
|
87
.gitignore
vendored
87
.gitignore
vendored
@ -1,87 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
/data/
|
||||
*.db
|
||||
*.db-*
|
||||
*.log
|
||||
/tmp/
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# Environment files
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Claude Code session tracking
|
||||
/tmp/claude-session-id
|
@ -1,239 +0,0 @@
|
||||
# Claude Code Project Tracker - Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
We've successfully built a comprehensive development intelligence system that tracks your Claude Code sessions and provides detailed insights into your coding patterns, productivity, and learning journey.
|
||||
|
||||
## What We've Built
|
||||
|
||||
### 🏗️ Architecture Components
|
||||
|
||||
1. **FastAPI Backend** (`main.py`)
|
||||
- RESTful API with full CRUD operations
|
||||
- Async/await support for better performance
|
||||
- Automatic OpenAPI documentation at `/docs`
|
||||
- Health check endpoint
|
||||
|
||||
2. **Database Layer** (`app/models/`, `app/database/`)
|
||||
- SQLAlchemy with async support
|
||||
- Six main entities: Projects, Sessions, Conversations, Activities, WaitingPeriods, GitOperations
|
||||
- Comprehensive relationships and computed properties
|
||||
- SQLite for local storage
|
||||
|
||||
3. **API Endpoints** (`app/api/`)
|
||||
- **Sessions**: Start/end development sessions
|
||||
- **Conversations**: Track dialogue with Claude
|
||||
- **Activities**: Record tool usage and file operations
|
||||
- **Waiting**: Monitor think times and engagement
|
||||
- **Git**: Track repository operations
|
||||
- **Projects**: Manage and query project data
|
||||
- **Analytics**: Advanced productivity insights
|
||||
|
||||
4. **Web Dashboard** (`app/dashboard/`)
|
||||
- Modern Bootstrap-based interface
|
||||
- Real-time charts and metrics
|
||||
- Project overview and timeline views
|
||||
- Conversation search functionality
|
||||
- Analytics and insights visualization
|
||||
|
||||
5. **Hook Integration** (`config/claude-hooks.json`)
|
||||
- Complete hook configuration for Claude Code
|
||||
- Automatic session tracking
|
||||
- Real-time data capture
|
||||
- Dynamic session ID management
|
||||
|
||||
### 🧪 Testing Infrastructure
|
||||
|
||||
- **Comprehensive test suite** with pytest
|
||||
- **Database fixtures** for realistic test scenarios
|
||||
- **API integration tests** covering all endpoints
|
||||
- **Hook simulation tests** for validation
|
||||
- **Sample data generators** for development
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- **Complete API specification** (OpenAPI/Swagger)
|
||||
- **Database schema documentation** with ERD
|
||||
- **Hook setup guide** with examples
|
||||
- **Development guide** for contributors
|
||||
- **Architecture overview** in README
|
||||
|
||||
## Key Features
|
||||
|
||||
### 📊 Analytics & Insights
|
||||
|
||||
- **Productivity Metrics**: Engagement scores, session analytics, think time analysis
|
||||
- **Development Patterns**: Working hours, tool usage, problem-solving approaches
|
||||
- **Learning Insights**: Topic frequency, skill development, complexity progression
|
||||
- **Git Intelligence**: Commit patterns, change analysis, repository health
|
||||
|
||||
### 💻 Real-time Tracking
|
||||
|
||||
- **Session Management**: Automatic start/stop with context capture
|
||||
- **Conversation Logging**: Full dialogue history with tool correlation
|
||||
- **Activity Monitoring**: Every tool use, file operation, and command execution
|
||||
- **Engagement Analysis**: Think times, flow states, productivity scoring
|
||||
|
||||
### 🔍 Advanced Search
|
||||
|
||||
- **Semantic Conversation Search**: Find discussions by meaning, not just keywords
|
||||
- **Project Filtering**: Focus on specific codebases
|
||||
- **Timeline Views**: Chronological development history
|
||||
- **Context Preservation**: Maintain conversation threads and outcomes
|
||||
|
||||
### 📈 Visual Dashboard
|
||||
|
||||
- **Interactive Charts**: Productivity trends, tool usage, engagement patterns
|
||||
- **Project Overview**: Statistics, language analysis, activity heatmaps
|
||||
- **Real-time Updates**: Auto-refresh every 5 minutes
|
||||
- **Responsive Design**: Works on desktop and mobile
|
||||
|
||||
## Data Model
|
||||
|
||||
### Core Entities
|
||||
|
||||
1. **Projects** - Development projects with metadata
|
||||
2. **Sessions** - Individual development sessions
|
||||
3. **Conversations** - User-Claude dialogue exchanges
|
||||
4. **Activities** - Tool usage and file operations
|
||||
5. **WaitingPeriods** - Think time and engagement tracking
|
||||
6. **GitOperations** - Version control activity
|
||||
|
||||
### Key Relationships
|
||||
|
||||
- Projects contain multiple Sessions
|
||||
- Sessions have Conversations, Activities, WaitingPeriods, and GitOperations
|
||||
- Activities can be linked to specific Conversations
|
||||
- Comprehensive foreign key relationships maintain data integrity
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Initialize database
|
||||
python -m app.database.init_db
|
||||
|
||||
# Start server
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 2. Hook Setup
|
||||
|
||||
```bash
|
||||
# Copy hook configuration to Claude Code
|
||||
cp config/claude-hooks.json ~/.config/claude-code/settings.json
|
||||
```
|
||||
|
||||
### 3. Access Dashboard
|
||||
|
||||
- **Web Interface**: http://localhost:8000
|
||||
- **API Documentation**: http://localhost:8000/docs
|
||||
- **Health Check**: http://localhost:8000/health
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
claude-tracker/
|
||||
├── main.py # FastAPI application
|
||||
├── requirements.txt # Dependencies
|
||||
├── .env # Configuration
|
||||
├── app/
|
||||
│ ├── models/ # Database models
|
||||
│ ├── api/ # REST endpoints
|
||||
│ ├── database/ # DB connection & init
|
||||
│ └── dashboard/ # Web interface
|
||||
├── tests/ # Comprehensive test suite
|
||||
├── docs/ # Technical documentation
|
||||
├── config/ # Hook configuration
|
||||
└── data/ # SQLite database (created at runtime)
|
||||
```
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `/api/session/start` | POST | Begin development session |
|
||||
| `/api/session/end` | POST | End development session |
|
||||
| `/api/conversation` | POST | Log dialogue exchange |
|
||||
| `/api/activity` | POST | Record tool usage |
|
||||
| `/api/waiting/start` | POST | Begin waiting period |
|
||||
| `/api/waiting/end` | POST | End waiting period |
|
||||
| `/api/git` | POST | Record git operation |
|
||||
| `/api/projects` | GET | List all projects |
|
||||
| `/api/projects/{id}/timeline` | GET | Project development timeline |
|
||||
| `/api/analytics/productivity` | GET | Productivity metrics |
|
||||
| `/api/analytics/patterns` | GET | Development patterns |
|
||||
| `/api/conversations/search` | GET | Search conversation history |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Run specific test categories
|
||||
pytest -m api # API tests
|
||||
pytest -m integration # Integration tests
|
||||
pytest -m hooks # Hook simulation tests
|
||||
```
|
||||
|
||||
## Analytics Capabilities
|
||||
|
||||
### Productivity Intelligence
|
||||
- Engagement scoring based on response patterns
|
||||
- Session quality assessment
|
||||
- Tool efficiency analysis
|
||||
- Time allocation insights
|
||||
|
||||
### Learning Analytics
|
||||
- Topic frequency and progression
|
||||
- Skill development velocity
|
||||
- Question complexity evolution
|
||||
- Knowledge retention patterns
|
||||
|
||||
### Development Intelligence
|
||||
- Code change patterns
|
||||
- Problem-solving approaches
|
||||
- Workflow optimization opportunities
|
||||
- Cross-project learning transfer
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
- **Local Storage**: All data remains on your machine
|
||||
- **No External Dependencies**: No cloud services required
|
||||
- **Full Data Ownership**: Complete control over your development history
|
||||
- **Configurable Tracking**: Enable/disable features per project
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
The system is designed for extensibility:
|
||||
|
||||
- **Export Capabilities**: JSON, CSV, and report generation
|
||||
- **Advanced Visualizations**: 3D charts, network graphs, heat maps
|
||||
- **Machine Learning**: Predictive productivity modeling
|
||||
- **Integration**: IDE plugins, CI/CD pipeline hooks
|
||||
- **Collaboration**: Team analytics and shared insights
|
||||
|
||||
## Success Metrics
|
||||
|
||||
This implementation provides:
|
||||
|
||||
1. **Complete Development History**: Every interaction tracked and searchable
|
||||
2. **Actionable Insights**: Data-driven productivity improvements
|
||||
3. **Learning Acceleration**: Pattern recognition for skill development
|
||||
4. **Workflow Optimization**: Identify and eliminate inefficiencies
|
||||
5. **Knowledge Retention**: Preserve problem-solving approaches and solutions
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Claude Code Project Tracker transforms your development process into a rich source of insights and intelligence. By automatically capturing every aspect of your coding journey, it provides unprecedented visibility into how you work, learn, and grow as a developer.
|
||||
|
||||
The system is production-ready, thoroughly tested, and designed to scale with your development needs while maintaining complete privacy and control over your data.
|
147
README.md
147
README.md
@ -1,146 +1,3 @@
|
||||
# Claude Code Project Tracker
|
||||
# claude-code-tracker
|
||||
|
||||
A comprehensive development intelligence system that tracks your Claude Code sessions, providing insights into your coding patterns, productivity, and learning journey.
|
||||
|
||||
## Overview
|
||||
|
||||
The Claude Code Project Tracker automatically captures your development workflow through Claude Code's hook system, creating a detailed record of:
|
||||
|
||||
- **Development Sessions** - When you start/stop working, what projects you focus on
|
||||
- **Conversations** - Full dialogue history with Claude for context and learning analysis
|
||||
- **Code Changes** - File modifications, tool usage, and command executions
|
||||
- **Thinking Patterns** - Wait times between interactions to understand your workflow
|
||||
- **Git Activity** - Repository changes, commits, and branch operations
|
||||
- **Productivity Metrics** - Engagement levels, output quality, and learning velocity
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Claude Code │───▶│ Hook System │───▶│ FastAPI Server │
|
||||
│ (your IDE) │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Web Dashboard │◀───│ Analytics │◀───│ SQLite Database │
|
||||
│ │ │ Engine │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- **FastAPI Server**: REST API that receives hook data and serves analytics
|
||||
- **SQLite Database**: Local storage for all tracking data
|
||||
- **Hook Integration**: Claude Code hooks that capture development events
|
||||
- **Analytics Engine**: Processes raw data into meaningful insights
|
||||
- **Web Dashboard**: Interactive interface for exploring your development patterns
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🎯 Session Tracking
|
||||
- Automatic project detection and session management
|
||||
- Working directory and git branch context
|
||||
- Session duration and engagement analysis
|
||||
|
||||
### 💬 Conversation Intelligence
|
||||
- Full dialogue history with semantic search
|
||||
- Problem-solving pattern recognition
|
||||
- Learning topic identification and progress tracking
|
||||
|
||||
### 📊 Development Analytics
|
||||
- Productivity metrics and engagement scoring
|
||||
- Tool usage patterns and optimization insights
|
||||
- Cross-project learning and code reuse analysis
|
||||
|
||||
### 🔍 Advanced Insights
|
||||
- Think time analysis and flow state detection
|
||||
- Git activity correlation with conversations
|
||||
- Skill development velocity tracking
|
||||
- Workflow optimization recommendations
|
||||
|
||||
## Data Privacy
|
||||
|
||||
- **Local-First**: All data stays on your machine
|
||||
- **No External Services**: No data transmission to third parties
|
||||
- **Full Control**: Complete ownership of your development history
|
||||
- **Selective Tracking**: Configurable hook activation per project
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **Start the Tracking Server**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
3. **Configure Claude Code Hooks**
|
||||
```bash
|
||||
# Add hooks to your Claude Code settings
|
||||
cp config/claude-hooks.json ~/.config/claude-code/
|
||||
```
|
||||
|
||||
4. **Access Dashboard**
|
||||
```
|
||||
Open http://localhost:8000 in your browser
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
claude-tracker/
|
||||
├── README.md # This file
|
||||
├── requirements.txt # Python dependencies
|
||||
├── main.py # FastAPI application entry point
|
||||
├── config/ # Configuration files
|
||||
│ └── claude-hooks.json # Hook setup for Claude Code
|
||||
├── app/ # Application code
|
||||
│ ├── models/ # Database models
|
||||
│ ├── api/ # API endpoints
|
||||
│ ├── analytics/ # Insights engine
|
||||
│ └── dashboard/ # Web interface
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Detailed documentation
|
||||
└── data/ # SQLite database location
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [API Specification](docs/api-spec.yaml) - Complete endpoint documentation
|
||||
- [Database Schema](docs/database-schema.md) - Data model details
|
||||
- [Hook Setup Guide](docs/hook-setup.md) - Claude Code integration
|
||||
- [Development Guide](docs/development.md) - Local setup and contribution
|
||||
- [Analytics Guide](docs/analytics.md) - Understanding insights and metrics
|
||||
|
||||
## Development
|
||||
|
||||
See [Development Guide](docs/development.md) for detailed setup instructions.
|
||||
|
||||
```bash
|
||||
# Install development dependencies
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Start development server with hot reload
|
||||
uvicorn main:app --reload
|
||||
|
||||
# Generate API documentation
|
||||
python -m app.generate_docs
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This project follows test-driven development. Please ensure:
|
||||
1. All new features have comprehensive tests
|
||||
2. Documentation is updated for API changes
|
||||
3. Analytics insights are validated with test data
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
Comprehensive development intelligence system for Claude Code sessions - tracks productivity, conversations, and learning patterns
|
@ -1 +0,0 @@
|
||||
# Claude Code Project Tracker
|
@ -1,3 +0,0 @@
|
||||
"""
|
||||
API modules for the Claude Code Project Tracker.
|
||||
"""
|
@ -1,304 +0,0 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
@ -1,483 +0,0 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
@ -1,220 +0,0 @@
|
||||
"""
|
||||
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
360
app/api/git.py
@ -1,360 +0,0 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
@ -1,430 +0,0 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
@ -1,220 +0,0 @@
|
||||
"""
|
||||
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
|
@ -1,255 +0,0 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
@ -1,285 +0,0 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
@ -1,3 +0,0 @@
|
||||
"""
|
||||
Web dashboard for the Claude Code Project Tracker.
|
||||
"""
|
@ -1,49 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
})
|
@ -1,346 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
@ -1,251 +0,0 @@
|
||||
/**
|
||||
* 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) + '...';
|
||||
}
|
||||
};
|
@ -1,327 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
@ -1,390 +0,0 @@
|
||||
{% 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 %}
|
@ -1,94 +0,0 @@
|
||||
<!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>
|
@ -1,334 +0,0 @@
|
||||
{% 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 %}
|
@ -1,207 +0,0 @@
|
||||
{% 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 %}
|
@ -1,192 +0,0 @@
|
||||
{% 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 %}
|
@ -1,7 +0,0 @@
|
||||
"""
|
||||
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"]
|
@ -1,71 +0,0 @@
|
||||
"""
|
||||
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.")
|
@ -1,24 +0,0 @@
|
||||
"""
|
||||
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())
|
@ -1,21 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
@ -1,162 +0,0 @@
|
||||
"""
|
||||
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
|
@ -1,32 +0,0 @@
|
||||
"""
|
||||
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
|
||||
)
|
@ -1,118 +0,0 @@
|
||||
"""
|
||||
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)
|
@ -1,202 +0,0 @@
|
||||
"""
|
||||
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"
|
@ -1,69 +0,0 @@
|
||||
"""
|
||||
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()
|
@ -1,134 +0,0 @@
|
||||
"""
|
||||
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)
|
@ -1,156 +0,0 @@
|
||||
"""
|
||||
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
|
@ -1,63 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"command": "curl -s -X POST http://localhost:8000/api/session/start -H 'Content-Type: application/json' -d '{\"session_type\":\"startup\",\"working_directory\":\"'\"$PWD\"'\",\"git_branch\":\"'$(git branch --show-current 2>/dev/null || echo \"unknown\")'\",\"git_repo\":\"'$(git config --get remote.origin.url 2>/dev/null || echo \"null\")'\",\"environment\":{\"pwd\":\"'\"$PWD\"'\",\"user\":\"'\"$USER\"'\",\"timestamp\":\"'$(date -Iseconds)'\"}}' > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "resume",
|
||||
"command": "curl -s -X POST http://localhost:8000/api/session/start -H 'Content-Type: application/json' -d '{\"session_type\":\"resume\",\"working_directory\":\"'\"$PWD\"'\",\"git_branch\":\"'$(git branch --show-current 2>/dev/null || echo \"unknown\")'\",\"git_repo\":\"'$(git config --get remote.origin.url 2>/dev/null || echo \"null\")'\",\"environment\":{\"pwd\":\"'\"$PWD\"'\",\"user\":\"'\"$USER\"'\",\"timestamp\":\"'$(date -Iseconds)'\"}}' > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "clear",
|
||||
"command": "curl -s -X POST http://localhost:8000/api/session/start -H 'Content-Type: application/json' -d '{\"session_type\":\"clear\",\"working_directory\":\"'\"$PWD\"'\",\"git_branch\":\"'$(git branch --show-current 2>/dev/null || echo \"unknown\")'\",\"git_repo\":\"'$(git config --get remote.origin.url 2>/dev/null || echo \"null\")'\",\"environment\":{\"pwd\":\"'\"$PWD\"'\",\"user\":\"'\"$USER\"'\",\"timestamp\":\"'$(date -Iseconds)'\"}}' > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"timestamp\":\"'$(date -Iseconds)'\",\"user_prompt\":\"'$(echo \"$CLAUDE_USER_PROMPT\" | sed 's/\"/\\\\\"/g' | tr '\\n' ' ')'\",\"exchange_type\":\"user_prompt\"}' | curl -s -X POST http://localhost:8000/api/conversation -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); curl -s -X POST http://localhost:8000/api/waiting/start -H 'Content-Type: application/json' -d '{\"session_id\":'$SESSION_ID',\"timestamp\":\"'$(date -Iseconds)'\",\"context_before\":\"Claude is waiting for input\"}' > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Edit\",\"action\":\"file_edit\",\"file_path\":\"'\"$CLAUDE_TOOL_FILE_PATH\"'\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Write\",\"action\":\"file_write\",\"file_path\":\"'\"$CLAUDE_TOOL_FILE_PATH\"'\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Read\",\"action\":\"file_read\",\"file_path\":\"'\"$CLAUDE_TOOL_FILE_PATH\"'\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); SAFE_COMMAND=$(echo \"$CLAUDE_BASH_COMMAND\" | sed 's/\"/\\\\\"/g' | tr '\\n' ' '); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Bash\",\"action\":\"command_execution\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"command\":\"'$SAFE_COMMAND'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Grep",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); SAFE_PATTERN=$(echo \"$CLAUDE_GREP_PATTERN\" | sed 's/\"/\\\\\"/g'); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Grep\",\"action\":\"search\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"pattern\":\"'$SAFE_PATTERN'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Glob",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Glob\",\"action\":\"file_search\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"pattern\":\"'\"$CLAUDE_GLOB_PATTERN\"'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"tool_name\":\"Task\",\"action\":\"subagent_call\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"task_type\":\"'\"$CLAUDE_TASK_TYPE\"'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"command": "CLAUDE_SESSION_FILE='/tmp/claude-session-id'; SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo '1'); echo '{\"session_id\":'$SESSION_ID',\"timestamp\":\"'$(date -Iseconds)'\",\"claude_response\":\"Response completed\",\"exchange_type\":\"claude_response\"}' | curl -s -X POST http://localhost:8000/api/conversation -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 && curl -s -X POST http://localhost:8000/api/waiting/end -H 'Content-Type: application/json' -d '{\"session_id\":'$SESSION_ID',\"timestamp\":\"'$(date -Iseconds)'\"}' > /dev/null 2>&1 &"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -1,530 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Claude Code Project Tracker API
|
||||
description: |
|
||||
REST API for tracking Claude Code development sessions, conversations, and productivity metrics.
|
||||
|
||||
This API is designed to be called by Claude Code hooks to automatically capture development workflow data.
|
||||
version: 1.0.0
|
||||
license:
|
||||
name: MIT
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8000
|
||||
description: Local development server
|
||||
|
||||
paths:
|
||||
# Session Management
|
||||
/api/session/start:
|
||||
post:
|
||||
summary: Start a new development session
|
||||
description: Called by SessionStart hook to initialize project tracking
|
||||
tags:
|
||||
- Sessions
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionStart'
|
||||
responses:
|
||||
'201':
|
||||
description: Session created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
'400':
|
||||
description: Invalid request data
|
||||
|
||||
/api/session/end:
|
||||
post:
|
||||
summary: End current development session
|
||||
description: Called by Stop hook to finalize session tracking
|
||||
tags:
|
||||
- Sessions
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionEnd'
|
||||
responses:
|
||||
'200':
|
||||
description: Session ended successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
|
||||
# Conversation Tracking
|
||||
/api/conversation:
|
||||
post:
|
||||
summary: Log conversation exchange
|
||||
description: Called by UserPromptSubmit and Stop hooks to capture dialogue
|
||||
tags:
|
||||
- Conversations
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConversationEntry'
|
||||
responses:
|
||||
'201':
|
||||
description: Conversation logged successfully
|
||||
|
||||
# Activity Tracking
|
||||
/api/activity:
|
||||
post:
|
||||
summary: Record development activity
|
||||
description: Called by PostToolUse hooks to track tool usage and file operations
|
||||
tags:
|
||||
- Activities
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Activity'
|
||||
responses:
|
||||
'201':
|
||||
description: Activity recorded successfully
|
||||
|
||||
# Waiting Period Tracking
|
||||
/api/waiting/start:
|
||||
post:
|
||||
summary: Start waiting period
|
||||
description: Called by Notification hook when Claude is waiting for input
|
||||
tags:
|
||||
- Waiting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WaitingStart'
|
||||
responses:
|
||||
'201':
|
||||
description: Waiting period started
|
||||
|
||||
/api/waiting/end:
|
||||
post:
|
||||
summary: End waiting period
|
||||
description: Called when user submits new input
|
||||
tags:
|
||||
- Waiting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WaitingEnd'
|
||||
responses:
|
||||
'200':
|
||||
description: Waiting period ended
|
||||
|
||||
# Git Operations
|
||||
/api/git:
|
||||
post:
|
||||
summary: Record git operation
|
||||
description: Track git commands and repository state changes
|
||||
tags:
|
||||
- Git
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GitOperation'
|
||||
responses:
|
||||
'201':
|
||||
description: Git operation recorded
|
||||
|
||||
# Data Retrieval
|
||||
/api/projects:
|
||||
get:
|
||||
summary: List all tracked projects
|
||||
description: Get overview of all projects with summary statistics
|
||||
tags:
|
||||
- Projects
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
'200':
|
||||
description: List of projects
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProjectSummary'
|
||||
|
||||
/api/projects/{project_id}/timeline:
|
||||
get:
|
||||
summary: Get detailed project timeline
|
||||
description: Retrieve chronological history of project development
|
||||
tags:
|
||||
- Projects
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: start_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: end_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
responses:
|
||||
'200':
|
||||
description: Project timeline
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectTimeline'
|
||||
|
||||
# Analytics
|
||||
/api/analytics/productivity:
|
||||
get:
|
||||
summary: Get productivity analytics
|
||||
description: Retrieve engagement metrics and output analysis
|
||||
tags:
|
||||
- Analytics
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: days
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 30
|
||||
responses:
|
||||
'200':
|
||||
description: Productivity metrics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductivityMetrics'
|
||||
|
||||
/api/conversations/search:
|
||||
get:
|
||||
summary: Search conversations
|
||||
description: Semantic search through conversation history
|
||||
tags:
|
||||
- Conversations
|
||||
parameters:
|
||||
- name: query
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: project_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: Search results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ConversationSearchResult'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
# Session Schemas
|
||||
SessionStart:
|
||||
type: object
|
||||
required:
|
||||
- session_type
|
||||
- working_directory
|
||||
properties:
|
||||
session_type:
|
||||
type: string
|
||||
enum: [startup, resume, clear]
|
||||
working_directory:
|
||||
type: string
|
||||
git_branch:
|
||||
type: string
|
||||
git_repo:
|
||||
type: string
|
||||
environment:
|
||||
type: object
|
||||
|
||||
SessionEnd:
|
||||
type: object
|
||||
required:
|
||||
- session_id
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
end_reason:
|
||||
type: string
|
||||
enum: [normal, interrupted, timeout]
|
||||
|
||||
SessionResponse:
|
||||
type: object
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
project_id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
|
||||
# Conversation Schemas
|
||||
ConversationEntry:
|
||||
type: object
|
||||
required:
|
||||
- session_id
|
||||
- timestamp
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
user_prompt:
|
||||
type: string
|
||||
claude_response:
|
||||
type: string
|
||||
tools_used:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
files_affected:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
context:
|
||||
type: object
|
||||
|
||||
# Activity Schemas
|
||||
Activity:
|
||||
type: object
|
||||
required:
|
||||
- session_id
|
||||
- tool_name
|
||||
- timestamp
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
tool_name:
|
||||
type: string
|
||||
enum: [Edit, Write, Read, Bash, Grep, Glob, Task, WebFetch]
|
||||
action:
|
||||
type: string
|
||||
file_path:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
metadata:
|
||||
type: object
|
||||
success:
|
||||
type: boolean
|
||||
error_message:
|
||||
type: string
|
||||
|
||||
# Waiting Period Schemas
|
||||
WaitingStart:
|
||||
type: object
|
||||
required:
|
||||
- session_id
|
||||
- timestamp
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
context_before:
|
||||
type: string
|
||||
|
||||
WaitingEnd:
|
||||
type: object
|
||||
required:
|
||||
- session_id
|
||||
- timestamp
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
duration_seconds:
|
||||
type: number
|
||||
context_after:
|
||||
type: string
|
||||
|
||||
# Git Schemas
|
||||
GitOperation:
|
||||
type: object
|
||||
required:
|
||||
- session_id
|
||||
- operation
|
||||
- timestamp
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
operation:
|
||||
type: string
|
||||
enum: [commit, push, pull, branch, merge, rebase, status]
|
||||
command:
|
||||
type: string
|
||||
result:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
files_changed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
lines_added:
|
||||
type: integer
|
||||
lines_removed:
|
||||
type: integer
|
||||
|
||||
# Response Schemas
|
||||
ProjectSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
git_repo:
|
||||
type: string
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
total_sessions:
|
||||
type: integer
|
||||
total_time_minutes:
|
||||
type: integer
|
||||
last_activity:
|
||||
type: string
|
||||
format: date-time
|
||||
files_modified:
|
||||
type: integer
|
||||
lines_changed:
|
||||
type: integer
|
||||
|
||||
ProjectTimeline:
|
||||
type: object
|
||||
properties:
|
||||
project:
|
||||
$ref: '#/components/schemas/ProjectSummary'
|
||||
timeline:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
type:
|
||||
type: string
|
||||
enum: [session_start, session_end, conversation, activity, git_operation]
|
||||
data:
|
||||
type: object
|
||||
|
||||
ProductivityMetrics:
|
||||
type: object
|
||||
properties:
|
||||
engagement_score:
|
||||
type: number
|
||||
description: Overall engagement level (0-100)
|
||||
average_session_length:
|
||||
type: number
|
||||
description: Minutes per session
|
||||
think_time_average:
|
||||
type: number
|
||||
description: Average waiting time between interactions
|
||||
files_per_session:
|
||||
type: number
|
||||
tools_most_used:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
productivity_trends:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
score:
|
||||
type: number
|
||||
|
||||
ConversationSearchResult:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
project_name:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
user_prompt:
|
||||
type: string
|
||||
claude_response:
|
||||
type: string
|
||||
relevance_score:
|
||||
type: number
|
||||
context:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
tags:
|
||||
- name: Sessions
|
||||
description: Development session management
|
||||
- name: Conversations
|
||||
description: Dialogue tracking and search
|
||||
- name: Activities
|
||||
description: Tool usage and file operations
|
||||
- name: Waiting
|
||||
description: Think time and engagement tracking
|
||||
- name: Git
|
||||
description: Repository operations
|
||||
- name: Projects
|
||||
description: Project data retrieval
|
||||
- name: Analytics
|
||||
description: Insights and metrics
|
@ -1,252 +0,0 @@
|
||||
# Database Schema Documentation
|
||||
|
||||
This document describes the SQLite database schema for the Claude Code Project Tracker.
|
||||
|
||||
## Overview
|
||||
|
||||
The database is designed to capture comprehensive development workflow data through a normalized relational structure. All timestamps are stored in UTC format.
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ projects │ │ sessions │ │conversations│
|
||||
│ │ │ │ │ │
|
||||
│ id (PK) │◀──┤ project_id │ │ session_id │
|
||||
│ name │ │ id (PK) │◀──┤ id (PK) │
|
||||
│ path │ │ start_time │ │ timestamp │
|
||||
│ git_repo │ │ end_time │ │ user_prompt │
|
||||
│ created_at │ │ type │ │ claude_resp │
|
||||
│ ... │ │ ... │ │ ... │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
│
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ activities │ │waiting_perds│
|
||||
│ │ │ │
|
||||
│ session_id │ │ session_id │
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ tool_name │ │ start_time │
|
||||
│ file_path │ │ end_time │
|
||||
│ timestamp │ │ duration │
|
||||
│ ... │ │ ... │
|
||||
└─────────────┘ └─────────────┘
|
||||
│
|
||||
│
|
||||
┌─────────────┐
|
||||
│git_operations│
|
||||
│ │
|
||||
│ session_id │
|
||||
│ id (PK) │
|
||||
│ operation │
|
||||
│ command │
|
||||
│ timestamp │
|
||||
│ ... │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Table Definitions
|
||||
|
||||
### projects
|
||||
|
||||
Stores metadata about tracked projects.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY | Unique project identifier |
|
||||
| name | VARCHAR(255) | NOT NULL | Project display name |
|
||||
| path | TEXT | NOT NULL UNIQUE | Absolute filesystem path |
|
||||
| git_repo | VARCHAR(500) | NULL | Git repository URL if applicable |
|
||||
| languages | JSON | NULL | Array of detected programming languages |
|
||||
| created_at | TIMESTAMP | NOT NULL DEFAULT NOW() | First time project was tracked |
|
||||
| last_session | TIMESTAMP | NULL | Most recent session timestamp |
|
||||
| total_sessions | INTEGER | NOT NULL DEFAULT 0 | Count of development sessions |
|
||||
| total_time_minutes | INTEGER | NOT NULL DEFAULT 0 | Cumulative session duration |
|
||||
| files_modified_count | INTEGER | NOT NULL DEFAULT 0 | Total unique files changed |
|
||||
| lines_changed_count | INTEGER | NOT NULL DEFAULT 0 | Total lines added + removed |
|
||||
|
||||
**Indexes:**
|
||||
- `idx_projects_path` ON (path)
|
||||
- `idx_projects_last_session` ON (last_session)
|
||||
|
||||
### sessions
|
||||
|
||||
Individual development sessions within projects.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY | Unique session identifier |
|
||||
| project_id | INTEGER | NOT NULL FK(projects.id) | Associated project |
|
||||
| start_time | TIMESTAMP | NOT NULL | Session start timestamp |
|
||||
| end_time | TIMESTAMP | NULL | Session end timestamp (NULL if active) |
|
||||
| session_type | VARCHAR(50) | NOT NULL | startup, resume, clear |
|
||||
| working_directory | TEXT | NOT NULL | Directory path when session started |
|
||||
| git_branch | VARCHAR(255) | NULL | Active git branch |
|
||||
| environment | JSON | NULL | System environment details |
|
||||
| duration_minutes | INTEGER | NULL | Calculated session length |
|
||||
| activity_count | INTEGER | NOT NULL DEFAULT 0 | Number of tool uses |
|
||||
| conversation_count | INTEGER | NOT NULL DEFAULT 0 | Number of exchanges |
|
||||
| files_touched | JSON | NULL | Array of file paths accessed |
|
||||
|
||||
**Indexes:**
|
||||
- `idx_sessions_project_start` ON (project_id, start_time)
|
||||
- `idx_sessions_active` ON (end_time) WHERE end_time IS NULL
|
||||
|
||||
### conversations
|
||||
|
||||
Dialogue exchanges between user and Claude.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY | Unique conversation identifier |
|
||||
| session_id | INTEGER | NOT NULL FK(sessions.id) | Associated session |
|
||||
| timestamp | TIMESTAMP | NOT NULL | When exchange occurred |
|
||||
| user_prompt | TEXT | NULL | User's input message |
|
||||
| claude_response | TEXT | NULL | Claude's response |
|
||||
| tools_used | JSON | NULL | Array of tools used in response |
|
||||
| files_affected | JSON | NULL | Array of files mentioned/modified |
|
||||
| context | JSON | NULL | Additional context metadata |
|
||||
| tokens_input | INTEGER | NULL | Estimated input token count |
|
||||
| tokens_output | INTEGER | NULL | Estimated output token count |
|
||||
| exchange_type | VARCHAR(50) | NOT NULL | user_prompt, claude_response |
|
||||
|
||||
**Indexes:**
|
||||
- `idx_conversations_session_time` ON (session_id, timestamp)
|
||||
- `idx_conversations_search` ON (user_prompt, claude_response) USING fts5
|
||||
|
||||
### activities
|
||||
|
||||
Tool usage and file operations during development.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY | Unique activity identifier |
|
||||
| session_id | INTEGER | NOT NULL FK(sessions.id) | Associated session |
|
||||
| conversation_id | INTEGER | NULL FK(conversations.id) | Associated exchange |
|
||||
| timestamp | TIMESTAMP | NOT NULL | When activity occurred |
|
||||
| tool_name | VARCHAR(50) | NOT NULL | Edit, Write, Read, Bash, etc. |
|
||||
| action | VARCHAR(100) | NOT NULL | Specific action taken |
|
||||
| file_path | TEXT | NULL | Target file path if applicable |
|
||||
| metadata | JSON | NULL | Tool-specific data |
|
||||
| success | BOOLEAN | NOT NULL DEFAULT true | Whether operation succeeded |
|
||||
| error_message | TEXT | NULL | Error details if failed |
|
||||
| lines_added | INTEGER | NULL | Lines added (for Edit/Write) |
|
||||
| lines_removed | INTEGER | NULL | Lines removed (for Edit) |
|
||||
|
||||
**Indexes:**
|
||||
- `idx_activities_session_time` ON (session_id, timestamp)
|
||||
- `idx_activities_tool_file` ON (tool_name, file_path)
|
||||
|
||||
### waiting_periods
|
||||
|
||||
Time intervals when Claude is waiting for user input.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY | Unique waiting period identifier |
|
||||
| session_id | INTEGER | NOT NULL FK(sessions.id) | Associated session |
|
||||
| start_time | TIMESTAMP | NOT NULL | When waiting began |
|
||||
| end_time | TIMESTAMP | NULL | When user responded |
|
||||
| duration_seconds | INTEGER | NULL | Calculated wait duration |
|
||||
| context_before | TEXT | NULL | Claude's last message |
|
||||
| context_after | TEXT | NULL | User's next message |
|
||||
| likely_activity | VARCHAR(50) | NULL | thinking, research, external_work, break |
|
||||
|
||||
**Indexes:**
|
||||
- `idx_waiting_session_start` ON (session_id, start_time)
|
||||
- `idx_waiting_duration` ON (duration_seconds)
|
||||
|
||||
### git_operations
|
||||
|
||||
Git commands and repository state changes.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY | Unique git operation identifier |
|
||||
| session_id | INTEGER | NOT NULL FK(sessions.id) | Associated session |
|
||||
| timestamp | TIMESTAMP | NOT NULL | When operation occurred |
|
||||
| operation | VARCHAR(50) | NOT NULL | commit, push, pull, branch, etc. |
|
||||
| command | TEXT | NOT NULL | Full git command executed |
|
||||
| result | TEXT | NULL | Command output |
|
||||
| success | BOOLEAN | NOT NULL | Whether command succeeded |
|
||||
| files_changed | JSON | NULL | Array of affected files |
|
||||
| lines_added | INTEGER | NULL | Lines added in commit |
|
||||
| lines_removed | INTEGER | NULL | Lines removed in commit |
|
||||
| commit_hash | VARCHAR(40) | NULL | Git commit SHA |
|
||||
| branch_from | VARCHAR(255) | NULL | Source branch |
|
||||
| branch_to | VARCHAR(255) | NULL | Target branch |
|
||||
|
||||
**Indexes:**
|
||||
- `idx_git_session_time` ON (session_id, timestamp)
|
||||
- `idx_git_operation` ON (operation)
|
||||
- `idx_git_commit` ON (commit_hash)
|
||||
|
||||
## Analytics Views
|
||||
|
||||
### project_productivity_summary
|
||||
|
||||
Aggregated productivity metrics per project.
|
||||
|
||||
```sql
|
||||
CREATE VIEW project_productivity_summary AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.path,
|
||||
COUNT(DISTINCT s.id) as total_sessions,
|
||||
SUM(s.duration_minutes) as total_time_minutes,
|
||||
AVG(s.duration_minutes) as avg_session_minutes,
|
||||
COUNT(DISTINCT a.file_path) as unique_files_modified,
|
||||
SUM(a.lines_added + a.lines_removed) as total_lines_changed,
|
||||
AVG(wp.duration_seconds) as avg_think_time_seconds,
|
||||
MAX(s.start_time) as last_activity
|
||||
FROM projects p
|
||||
LEFT JOIN sessions s ON p.id = s.project_id
|
||||
LEFT JOIN activities a ON s.id = a.session_id
|
||||
LEFT JOIN waiting_periods wp ON s.id = wp.session_id
|
||||
GROUP BY p.id, p.name, p.path;
|
||||
```
|
||||
|
||||
### daily_productivity_metrics
|
||||
|
||||
Daily productivity trends across all projects.
|
||||
|
||||
```sql
|
||||
CREATE VIEW daily_productivity_metrics AS
|
||||
SELECT
|
||||
DATE(s.start_time) as date,
|
||||
COUNT(DISTINCT s.id) as sessions_count,
|
||||
SUM(s.duration_minutes) as total_time_minutes,
|
||||
COUNT(DISTINCT a.file_path) as files_modified,
|
||||
SUM(a.lines_added + a.lines_removed) as lines_changed,
|
||||
AVG(wp.duration_seconds) as avg_think_time,
|
||||
COUNT(DISTINCT s.project_id) as projects_worked_on
|
||||
FROM sessions s
|
||||
LEFT JOIN activities a ON s.id = a.session_id AND a.tool_name IN ('Edit', 'Write')
|
||||
LEFT JOIN waiting_periods wp ON s.id = wp.session_id
|
||||
WHERE s.end_time IS NOT NULL
|
||||
GROUP BY DATE(s.start_time)
|
||||
ORDER BY date DESC;
|
||||
```
|
||||
|
||||
## Data Retention
|
||||
|
||||
- **Conversation Full Text**: Stored indefinitely for search and analysis
|
||||
- **Activity Details**: Kept for all operations to maintain complete audit trail
|
||||
- **Analytics Aggregations**: Computed on-demand from source data
|
||||
- **Cleanup**: Manual cleanup tools provided, no automatic data expiration
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Database file size grows approximately 1-5MB per day of active development
|
||||
- Full-text search indexes require periodic optimization (`PRAGMA optimize`)
|
||||
- Analytics queries use covering indexes to avoid table scans
|
||||
- Large file content is not stored, only file paths and change metrics
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
Schema migrations are handled through versioned SQL scripts in `/migrations/`:
|
||||
- Each migration has up/down scripts
|
||||
- Version tracking in `schema_versions` table
|
||||
- Automatic backup before migrations
|
||||
- Rollback capability for failed migrations
|
@ -1,562 +0,0 @@
|
||||
# Development Setup Guide
|
||||
|
||||
This guide covers setting up a local development environment for the Claude Code Project Tracker.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+** with pip
|
||||
- **Git** for version control
|
||||
- **Node.js 16+** (for web dashboard development)
|
||||
- **SQLite3** (usually included with Python)
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd claude-tracker
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Initialize database
|
||||
python -m app.database.init_db
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Start development server
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
claude-tracker/
|
||||
├── main.py # FastAPI application entry point
|
||||
├── requirements.txt # Production dependencies
|
||||
├── requirements-dev.txt # Development dependencies
|
||||
├── pytest.ini # Pytest configuration
|
||||
├── .env.example # Environment variable template
|
||||
├── app/ # Main application code
|
||||
│ ├── __init__.py
|
||||
│ ├── models/ # Database models
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # Base model class
|
||||
│ │ ├── project.py # Project model
|
||||
│ │ ├── session.py # Session model
|
||||
│ │ └── ... # Other models
|
||||
│ ├── api/ # API route handlers
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── dependencies.py # FastAPI dependencies
|
||||
│ │ ├── sessions.py # Session endpoints
|
||||
│ │ ├── conversations.py # Conversation endpoints
|
||||
│ │ └── ... # Other endpoints
|
||||
│ ├── database/ # Database management
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── connection.py # Database connection
|
||||
│ │ ├── init_db.py # Database initialization
|
||||
│ │ └── migrations/ # Schema migrations
|
||||
│ ├── analytics/ # Analytics and insights engine
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── productivity.py # Productivity metrics
|
||||
│ │ ├── patterns.py # Pattern analysis
|
||||
│ │ └── reports.py # Report generation
|
||||
│ └── dashboard/ # Web dashboard
|
||||
│ ├── static/ # CSS, JS, images
|
||||
│ ├── templates/ # HTML templates
|
||||
│ └── routes.py # Dashboard routes
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest fixtures
|
||||
│ ├── test_models.py # Model tests
|
||||
│ ├── test_api.py # API tests
|
||||
│ ├── test_analytics.py # Analytics tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── config/ # Configuration files
|
||||
├── docs/ # Documentation
|
||||
├── data/ # Database files (created at runtime)
|
||||
└── migrations/ # Database migrations
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Dependencies (`requirements.txt`)
|
||||
|
||||
```
|
||||
fastapi==0.104.1 # Web framework
|
||||
uvicorn[standard]==0.24.0 # ASGI server
|
||||
sqlalchemy==2.0.23 # ORM
|
||||
sqlite3 # Database (built into Python)
|
||||
pydantic==2.5.0 # Data validation
|
||||
jinja2==3.1.2 # Template engine
|
||||
python-multipart==0.0.6 # Form parsing
|
||||
python-jose[cryptography]==3.3.0 # JWT tokens
|
||||
passlib[bcrypt]==1.7.4 # Password hashing
|
||||
```
|
||||
|
||||
### Development Dependencies (`requirements-dev.txt`)
|
||||
|
||||
```
|
||||
pytest==7.4.3 # Testing framework
|
||||
pytest-asyncio==0.21.1 # Async testing
|
||||
pytest-cov==4.1.0 # Coverage reporting
|
||||
httpx==0.25.2 # HTTP client for testing
|
||||
faker==20.1.0 # Test data generation
|
||||
black==23.11.0 # Code formatting
|
||||
isort==5.12.0 # Import sorting
|
||||
flake8==6.1.0 # Linting
|
||||
mypy==1.7.1 # Type checking
|
||||
pre-commit==3.6.0 # Git hooks
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Configure these variables in `.env`:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///./data/tracker.db
|
||||
|
||||
# API Configuration
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
DEBUG=true
|
||||
|
||||
# Security (generate with: openssl rand -hex 32)
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Analytics
|
||||
ENABLE_ANALYTICS=true
|
||||
ANALYTICS_BATCH_SIZE=1000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=tracker.log
|
||||
```
|
||||
|
||||
## Database Setup
|
||||
|
||||
### Initialize Database
|
||||
|
||||
```bash
|
||||
# Create database and tables
|
||||
python -m app.database.init_db
|
||||
|
||||
# Verify database creation
|
||||
sqlite3 data/tracker.db ".tables"
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```bash
|
||||
# Create a new migration
|
||||
python -m app.database.migrate create "add_new_field"
|
||||
|
||||
# Apply migrations
|
||||
python -m app.database.migrate up
|
||||
|
||||
# Rollback migration
|
||||
python -m app.database.migrate down
|
||||
```
|
||||
|
||||
### Sample Data
|
||||
|
||||
Load test data for development:
|
||||
|
||||
```bash
|
||||
# Load sample projects and sessions
|
||||
python -m app.database.seed_data
|
||||
|
||||
# Clear all data
|
||||
python -m app.database.clear_data
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_api.py
|
||||
|
||||
# Run tests matching pattern
|
||||
pytest -k "test_session"
|
||||
|
||||
# Run tests with verbose output
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### Test Database
|
||||
|
||||
Tests use a separate in-memory database:
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
# Create in-memory SQLite database for testing
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
# ... setup code
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```python
|
||||
# tests/test_api.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.main import app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session(test_db, test_client):
|
||||
response = await test_client.post(
|
||||
"/api/session/start",
|
||||
json={
|
||||
"session_type": "startup",
|
||||
"working_directory": "/test/path"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["session_id"] is not None
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Style
|
||||
|
||||
We use Black for formatting and isort for import sorting:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black app/ tests/
|
||||
|
||||
# Sort imports
|
||||
isort app/ tests/
|
||||
|
||||
# Check formatting without changes
|
||||
black --check app/ tests/
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Run flake8 linting
|
||||
flake8 app/ tests/
|
||||
|
||||
# Type checking with mypy
|
||||
mypy app/
|
||||
```
|
||||
|
||||
### Pre-commit Hooks
|
||||
|
||||
Install pre-commit hooks to run checks automatically:
|
||||
|
||||
```bash
|
||||
# Install hooks
|
||||
pre-commit install
|
||||
|
||||
# Run hooks manually
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Git Workflow
|
||||
|
||||
1. **Create feature branch:**
|
||||
```bash
|
||||
git checkout -b feature/new-analytics-endpoint
|
||||
```
|
||||
|
||||
2. **Make changes and test:**
|
||||
```bash
|
||||
# Make your changes
|
||||
pytest # Run tests
|
||||
black app/ tests/ # Format code
|
||||
```
|
||||
|
||||
3. **Commit changes:**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add new analytics endpoint for productivity metrics"
|
||||
```
|
||||
|
||||
4. **Push and create PR:**
|
||||
```bash
|
||||
git push origin feature/new-analytics-endpoint
|
||||
```
|
||||
|
||||
## API Development
|
||||
|
||||
### Adding New Endpoints
|
||||
|
||||
1. **Define Pydantic schemas** in `app/models/schemas.py`:
|
||||
```python
|
||||
class NewFeatureRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
```
|
||||
|
||||
2. **Create route handler** in appropriate module:
|
||||
```python
|
||||
@router.post("/api/new-feature", response_model=NewFeatureResponse)
|
||||
async def create_new_feature(
|
||||
request: NewFeatureRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# Implementation
|
||||
```
|
||||
|
||||
3. **Add tests** in `tests/test_api.py`:
|
||||
```python
|
||||
async def test_create_new_feature(test_client):
|
||||
# Test implementation
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
|
||||
Use SQLAlchemy with async/await:
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.project import Project
|
||||
|
||||
async def get_projects(db: AsyncSession, limit: int = 10):
|
||||
result = await db.execute(
|
||||
select(Project).limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
The dashboard uses vanilla HTML/CSS/JavaScript:
|
||||
|
||||
```
|
||||
app/dashboard/
|
||||
├── static/
|
||||
│ ├── css/
|
||||
│ │ └── dashboard.css
|
||||
│ ├── js/
|
||||
│ │ ├── dashboard.js
|
||||
│ │ ├── charts.js
|
||||
│ │ └── api-client.js
|
||||
│ └── images/
|
||||
└── templates/
|
||||
├── base.html
|
||||
├── dashboard.html
|
||||
└── projects.html
|
||||
```
|
||||
|
||||
### Adding New Dashboard Pages
|
||||
|
||||
1. **Create HTML template:**
|
||||
```html
|
||||
<!-- app/dashboard/templates/new-page.html -->
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<!-- Page content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
2. **Add route handler:**
|
||||
```python
|
||||
@dashboard_router.get("/new-page")
|
||||
async def new_page(request: Request):
|
||||
return templates.TemplateResponse("new-page.html", {"request": request})
|
||||
```
|
||||
|
||||
3. **Add JavaScript if needed:**
|
||||
```javascript
|
||||
// app/dashboard/static/js/new-page.js
|
||||
class NewPageController {
|
||||
// Page logic
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Logging
|
||||
|
||||
Configure logging in `main.py`:
|
||||
|
||||
```python
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("tracker.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### Database Debugging
|
||||
|
||||
Enable SQL query logging:
|
||||
|
||||
```python
|
||||
# In development.py
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=True # This logs all SQL queries
|
||||
)
|
||||
```
|
||||
|
||||
### API Debugging
|
||||
|
||||
Use FastAPI's automatic documentation:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
### Hook Debugging
|
||||
|
||||
Test hooks manually:
|
||||
|
||||
```bash
|
||||
# Test session start
|
||||
curl -X POST http://localhost:8000/api/session/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"session_type":"startup","working_directory":"'$(pwd)'"}'
|
||||
|
||||
# Check logs
|
||||
tail -f tracker.log
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Database Optimization
|
||||
|
||||
```bash
|
||||
# Analyze query performance
|
||||
sqlite3 data/tracker.db "EXPLAIN QUERY PLAN SELECT ..."
|
||||
|
||||
# Rebuild indexes
|
||||
sqlite3 data/tracker.db "REINDEX;"
|
||||
|
||||
# Vacuum database
|
||||
sqlite3 data/tracker.db "VACUUM;"
|
||||
```
|
||||
|
||||
### API Performance
|
||||
|
||||
```bash
|
||||
# Profile API endpoints
|
||||
pip install line_profiler
|
||||
kernprof -l -v app/api/sessions.py
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Setup
|
||||
|
||||
1. **Environment variables:**
|
||||
```bash
|
||||
DEBUG=false
|
||||
API_HOST=127.0.0.1
|
||||
```
|
||||
|
||||
2. **Database backup:**
|
||||
```bash
|
||||
cp data/tracker.db data/tracker.db.backup
|
||||
```
|
||||
|
||||
3. **Run with Gunicorn:**
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
|
||||
```
|
||||
|
||||
### Docker Setup
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. **Fork the repository**
|
||||
2. **Create a feature branch**
|
||||
3. **Add tests for new features**
|
||||
4. **Ensure all tests pass**
|
||||
5. **Follow code style guidelines**
|
||||
6. **Submit a pull request**
|
||||
|
||||
### Pull Request Checklist
|
||||
|
||||
- [ ] Tests added/updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] Code formatted with Black
|
||||
- [ ] Type hints added
|
||||
- [ ] No linting errors
|
||||
- [ ] Database migrations created if needed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Import errors:**
|
||||
```bash
|
||||
# Ensure you're in the virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Check Python path
|
||||
python -c "import sys; print(sys.path)"
|
||||
```
|
||||
|
||||
2. **Database locked:**
|
||||
```bash
|
||||
# Check for hanging processes
|
||||
ps aux | grep python
|
||||
|
||||
# Remove lock files
|
||||
rm data/tracker.db-wal data/tracker.db-shm
|
||||
```
|
||||
|
||||
3. **Port already in use:**
|
||||
```bash
|
||||
# Find process using port 8000
|
||||
lsof -i :8000
|
||||
|
||||
# Use different port
|
||||
uvicorn main:app --port 8001
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check the [API documentation](api-spec.yaml)
|
||||
- Review test files for usage examples
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join development discussions in issues
|
@ -1,279 +0,0 @@
|
||||
# Claude Code Hook Setup Guide
|
||||
|
||||
This guide explains how to configure Claude Code hooks to automatically track your development sessions with the Project Tracker API.
|
||||
|
||||
## Overview
|
||||
|
||||
Claude Code hooks are shell commands that execute in response to specific events. We'll configure hooks to send HTTP requests to our tracking API whenever key development events occur.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Claude Code Project Tracker** server running on `http://localhost:8000`
|
||||
2. **curl** or **httpie** available in your shell
|
||||
3. **jq** for JSON processing (recommended)
|
||||
|
||||
## Hook Configuration Location
|
||||
|
||||
Claude Code hooks are configured in your settings file:
|
||||
- **Linux/macOS**: `~/.config/claude-code/settings.json`
|
||||
- **Windows**: `%APPDATA%\claude-code\settings.json`
|
||||
|
||||
## Complete Hook Configuration
|
||||
|
||||
Add this hooks section to your Claude Code settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"command": "curl -s -X POST http://localhost:8000/api/session/start -H 'Content-Type: application/json' -d '{\"session_type\":\"startup\",\"working_directory\":\"'\"$PWD\"'\",\"git_branch\":\"'$(git branch --show-current 2>/dev/null || echo \"unknown\")'\",\"git_repo\":\"'$(git config --get remote.origin.url 2>/dev/null || echo \"null\")'\",\"environment\":{\"pwd\":\"'\"$PWD\"'\",\"user\":\"'\"$USER\"'\",\"timestamp\":\"'$(date -Iseconds)'\"}}' > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "resume",
|
||||
"command": "curl -s -X POST http://localhost:8000/api/session/start -H 'Content-Type: application/json' -d '{\"session_type\":\"resume\",\"working_directory\":\"'\"$PWD\"'\",\"git_branch\":\"'$(git branch --show-current 2>/dev/null || echo \"unknown\")'\",\"git_repo\":\"'$(git config --get remote.origin.url 2>/dev/null || echo \"null\")'\",\"environment\":{\"pwd\":\"'\"$PWD\"'\",\"user\":\"'\"$USER\"'\",\"timestamp\":\"'$(date -Iseconds)'\"}}' > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "clear",
|
||||
"command": "curl -s -X POST http://localhost:8000/api/session/start -H 'Content-Type: application/json' -d '{\"session_type\":\"clear\",\"working_directory\":\"'\"$PWD\"'\",\"git_branch\":\"'$(git branch --show-current 2>/dev/null || echo \"unknown\")'\",\"git_repo\":\"'$(git config --get remote.origin.url 2>/dev/null || echo \"null\")'\",\"environment\":{\"pwd\":\"'\"$PWD\"'\",\"user\":\"'\"$USER\"'\",\"timestamp\":\"'$(date -Iseconds)'\"}}' > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"command": "echo '{\"session_id\":1,\"timestamp\":\"'$(date -Iseconds)'\",\"user_prompt\":\"'\"$CLAUDE_USER_PROMPT\"'\",\"exchange_type\":\"user_prompt\"}' | curl -s -X POST http://localhost:8000/api/conversation -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"command": "curl -s -X POST http://localhost:8000/api/waiting/start -H 'Content-Type: application/json' -d '{\"session_id\":1,\"timestamp\":\"'$(date -Iseconds)'\",\"context_before\":\"Claude is waiting for input\"}' > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Edit\",\"action\":\"file_edit\",\"file_path\":\"'\"$CLAUDE_TOOL_FILE_PATH\"'\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Write\",\"action\":\"file_write\",\"file_path\":\"'\"$CLAUDE_TOOL_FILE_PATH\"'\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Read\",\"action\":\"file_read\",\"file_path\":\"'\"$CLAUDE_TOOL_FILE_PATH\"'\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Bash\",\"action\":\"command_execution\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"command\":\"'\"$CLAUDE_BASH_COMMAND\"'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Grep",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Grep\",\"action\":\"search\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"pattern\":\"'\"$CLAUDE_GREP_PATTERN\"'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Glob",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Glob\",\"action\":\"file_search\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"pattern\":\"'\"$CLAUDE_GLOB_PATTERN\"'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"command": "echo '{\"session_id\":1,\"tool_name\":\"Task\",\"action\":\"subagent_call\",\"timestamp\":\"'$(date -Iseconds)'\",\"metadata\":{\"task_type\":\"'\"$CLAUDE_TASK_TYPE\"'\",\"success\":true},\"success\":true}' | curl -s -X POST http://localhost:8000/api/activity -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 &"
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"command": "echo '{\"session_id\":1,\"timestamp\":\"'$(date -Iseconds)'\",\"claude_response\":\"Response completed\",\"exchange_type\":\"claude_response\"}' | curl -s -X POST http://localhost:8000/api/conversation -H 'Content-Type: application/json' -d @- > /dev/null 2>&1 && curl -s -X POST http://localhost:8000/api/waiting/end -H 'Content-Type: application/json' -d '{\"session_id\":1,\"timestamp\":\"'$(date -Iseconds)'\"}' > /dev/null 2>&1 &"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Simplified Hook Configuration
|
||||
|
||||
For easier setup, you can use our provided configuration file:
|
||||
|
||||
```bash
|
||||
# Copy the hook configuration to Claude Code settings
|
||||
cp config/claude-hooks.json ~/.config/claude-code/
|
||||
```
|
||||
|
||||
## Hook Details
|
||||
|
||||
### SessionStart Hooks
|
||||
|
||||
Triggered when starting Claude Code or resuming a session:
|
||||
- **startup**: Fresh start of Claude Code
|
||||
- **resume**: Resuming after --resume flag
|
||||
- **clear**: Starting after clearing history
|
||||
|
||||
**Data Captured:**
|
||||
- Working directory
|
||||
- Git branch and repository
|
||||
- System environment info
|
||||
- Session type
|
||||
|
||||
### UserPromptSubmit Hook
|
||||
|
||||
Triggered when you submit a prompt to Claude:
|
||||
|
||||
**Data Captured:**
|
||||
- Your input message
|
||||
- Timestamp
|
||||
- Session context
|
||||
|
||||
### Notification Hook
|
||||
|
||||
Triggered when Claude is waiting for input:
|
||||
|
||||
**Data Captured:**
|
||||
- Wait start time
|
||||
- Context before waiting
|
||||
|
||||
### PostToolUse Hooks
|
||||
|
||||
Triggered after successful tool execution:
|
||||
|
||||
**Tools Tracked:**
|
||||
- **Edit/Write**: File modifications
|
||||
- **Read**: File examinations
|
||||
- **Bash**: Command executions
|
||||
- **Grep/Glob**: Search operations
|
||||
- **Task**: Subagent usage
|
||||
|
||||
**Data Captured:**
|
||||
- Tool name and action
|
||||
- Target files
|
||||
- Success/failure status
|
||||
- Tool-specific metadata
|
||||
|
||||
### Stop Hook
|
||||
|
||||
Triggered when Claude finishes responding:
|
||||
|
||||
**Data Captured:**
|
||||
- Response completion
|
||||
- End of waiting period
|
||||
- Session activity summary
|
||||
|
||||
## Testing Hook Configuration
|
||||
|
||||
1. **Start the tracker server:**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
2. **Verify hooks are working:**
|
||||
```bash
|
||||
# Check server logs for incoming requests
|
||||
tail -f tracker.log
|
||||
|
||||
# Test a simple operation in Claude Code
|
||||
# You should see API calls in the logs
|
||||
```
|
||||
|
||||
3. **Manual hook testing:**
|
||||
```bash
|
||||
# Test session start hook
|
||||
curl -X POST http://localhost:8000/api/session/start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"session_type":"startup","working_directory":"'$(pwd)'"}'
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The hooks can use these Claude Code environment variables:
|
||||
|
||||
- `$CLAUDE_USER_PROMPT` - User's input message
|
||||
- `$CLAUDE_TOOL_FILE_PATH` - File path for file operations
|
||||
- `$CLAUDE_BASH_COMMAND` - Bash command being executed
|
||||
- `$CLAUDE_GREP_PATTERN` - Search pattern for Grep
|
||||
- `$CLAUDE_GLOB_PATTERN` - File pattern for Glob
|
||||
- `$CLAUDE_TASK_TYPE` - Type of Task/subagent
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks Not Firing
|
||||
|
||||
1. **Check Claude Code settings syntax:**
|
||||
```bash
|
||||
# Validate JSON syntax
|
||||
python -m json.tool ~/.config/claude-code/settings.json
|
||||
```
|
||||
|
||||
2. **Verify tracker server is running:**
|
||||
```bash
|
||||
curl http://localhost:8000/api/projects
|
||||
```
|
||||
|
||||
3. **Check hook command syntax:**
|
||||
```bash
|
||||
# Test commands manually in shell
|
||||
echo "Testing hook command..."
|
||||
```
|
||||
|
||||
### Missing Data
|
||||
|
||||
1. **Session ID issues**: The static `session_id: 1` in examples needs dynamic generation
|
||||
2. **JSON escaping**: Special characters in prompts/paths may break JSON
|
||||
3. **Network issues**: Hooks may fail if server is unreachable
|
||||
|
||||
### Performance Impact
|
||||
|
||||
- Hooks run asynchronously (`&` at end of commands)
|
||||
- Failed hook calls don't interrupt Claude Code operation
|
||||
- Network timeouts are handled gracefully
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Dynamic Session IDs
|
||||
|
||||
For production use, implement session ID management:
|
||||
|
||||
```bash
|
||||
# Store session ID in temp file
|
||||
CLAUDE_SESSION_FILE="/tmp/claude-session-id"
|
||||
|
||||
# In SessionStart hook:
|
||||
SESSION_ID=$(curl -s ... | jq -r '.session_id')
|
||||
echo $SESSION_ID > $CLAUDE_SESSION_FILE
|
||||
|
||||
# In other hooks:
|
||||
SESSION_ID=$(cat $CLAUDE_SESSION_FILE 2>/dev/null || echo "1")
|
||||
```
|
||||
|
||||
### Conditional Hook Execution
|
||||
|
||||
Skip tracking for certain directories:
|
||||
|
||||
```bash
|
||||
# Only track in specific directories
|
||||
if [[ "$PWD" =~ "/projects/" ]]; then
|
||||
curl -X POST ...
|
||||
fi
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Add error logging to hooks:
|
||||
|
||||
```bash
|
||||
curl ... 2>> ~/claude-tracker-errors.log || echo "Hook failed: $(date)" >> ~/claude-tracker-errors.log
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Hooks execute with your shell privileges
|
||||
- API calls are made to localhost only
|
||||
- No sensitive data is transmitted externally
|
||||
- Hook commands are logged in shell history
|
||||
|
||||
## Next Steps
|
||||
|
||||
After setting up hooks:
|
||||
|
||||
1. Start using Claude Code normally
|
||||
2. Check the web dashboard at http://localhost:8000
|
||||
3. Review captured data and analytics
|
||||
4. Adjust hook configuration as needed
|
||||
|
||||
For detailed API documentation, see [API Specification](api-spec.yaml).
|
81
main.py
81
main.py
@ -1,81 +0,0 @@
|
||||
"""
|
||||
Claude Code Project Tracker - FastAPI Application
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.database.connection import init_database, close_database
|
||||
from app.api import sessions, conversations, activities, waiting, git, projects, analytics
|
||||
from app.dashboard.routes import dashboard_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan management."""
|
||||
# Startup
|
||||
print("Starting Claude Code Project Tracker...")
|
||||
await init_database()
|
||||
yield
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
await close_database()
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Claude Code Project Tracker",
|
||||
description="API for tracking Claude Code development sessions and productivity",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, replace with specific origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routers
|
||||
app.include_router(sessions.router, prefix="/api", tags=["Sessions"])
|
||||
app.include_router(conversations.router, prefix="/api", tags=["Conversations"])
|
||||
app.include_router(activities.router, prefix="/api", tags=["Activities"])
|
||||
app.include_router(waiting.router, prefix="/api", tags=["Waiting Periods"])
|
||||
app.include_router(git.router, prefix="/api", tags=["Git Operations"])
|
||||
app.include_router(projects.router, prefix="/api", tags=["Projects"])
|
||||
app.include_router(analytics.router, prefix="/api", tags=["Analytics"])
|
||||
|
||||
# Include dashboard routes
|
||||
app.include_router(dashboard_router, tags=["Dashboard"])
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="app/dashboard/static"), name="static")
|
||||
|
||||
# Root redirect to dashboard
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Redirect root to dashboard."""
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "service": "claude-code-tracker"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
)
|
15
pytest.ini
15
pytest.ini
@ -1,15 +0,0 @@
|
||||
[tool:pytest]
|
||||
minversion = 6.0
|
||||
addopts = -ra -q --strict-markers --strict-config
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
markers =
|
||||
unit: Unit tests that don't require external dependencies
|
||||
integration: Integration tests that require database/API
|
||||
slow: Tests that take longer than 1 second to run
|
||||
api: API endpoint tests
|
||||
analytics: Analytics and insights tests
|
||||
hooks: Hook simulation tests
|
@ -1,10 +0,0 @@
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
httpx==0.25.2
|
||||
faker==20.1.0
|
||||
black==23.11.0
|
||||
isort==5.12.0
|
||||
flake8==6.1.0
|
||||
mypy==1.7.1
|
||||
pre-commit==3.6.0
|
@ -1,11 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
aiosqlite==0.19.0
|
||||
pydantic==2.5.0
|
||||
jinja2==3.1.2
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dateutil==2.8.2
|
||||
typing-extensions==4.8.0
|
@ -1,256 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.main import app
|
||||
from app.database.connection import get_db
|
||||
from app.models.base import Base
|
||||
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
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
connect_args={
|
||||
"check_same_thread": False,
|
||||
},
|
||||
poolclass=StaticPool,
|
||||
echo=False, # Set to True for SQL debugging
|
||||
)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create test database session."""
|
||||
# Create all tables
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Create session factory
|
||||
async_session = async_sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(test_db: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test HTTP client."""
|
||||
def override_get_db():
|
||||
return test_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
# Clean up
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_project(test_db: AsyncSession) -> Project:
|
||||
"""Create a sample project for testing."""
|
||||
project = Project(
|
||||
name="Test Project",
|
||||
path="/home/user/test-project",
|
||||
git_repo="https://github.com/user/test-project",
|
||||
languages=["python", "javascript"]
|
||||
)
|
||||
test_db.add(project)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(project)
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_session(test_db: AsyncSession, sample_project: Project) -> Session:
|
||||
"""Create a sample session for testing."""
|
||||
session = Session(
|
||||
project_id=sample_project.id,
|
||||
session_type="startup",
|
||||
working_directory="/home/user/test-project",
|
||||
git_branch="main",
|
||||
environment={"user": "testuser", "pwd": "/home/user/test-project"}
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(session)
|
||||
return session
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_conversation(test_db: AsyncSession, sample_session: Session) -> Conversation:
|
||||
"""Create a sample conversation for testing."""
|
||||
conversation = Conversation(
|
||||
session_id=sample_session.id,
|
||||
user_prompt="How do I implement a feature?",
|
||||
claude_response="You can implement it by following these steps...",
|
||||
tools_used=["Edit", "Write"],
|
||||
files_affected=["main.py", "utils.py"],
|
||||
exchange_type="user_prompt"
|
||||
)
|
||||
test_db.add(conversation)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(conversation)
|
||||
return conversation
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_activity(test_db: AsyncSession, sample_session: Session) -> Activity:
|
||||
"""Create a sample activity for testing."""
|
||||
activity = Activity(
|
||||
session_id=sample_session.id,
|
||||
tool_name="Edit",
|
||||
action="file_edit",
|
||||
file_path="/home/user/test-project/main.py",
|
||||
metadata={"lines_changed": 10},
|
||||
success=True,
|
||||
lines_added=5,
|
||||
lines_removed=2
|
||||
)
|
||||
test_db.add(activity)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(activity)
|
||||
return activity
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_waiting_period(test_db: AsyncSession, sample_session: Session) -> WaitingPeriod:
|
||||
"""Create a sample waiting period for testing."""
|
||||
waiting_period = WaitingPeriod(
|
||||
session_id=sample_session.id,
|
||||
duration_seconds=30,
|
||||
context_before="Claude finished responding",
|
||||
context_after="User asked a follow-up question",
|
||||
likely_activity="thinking"
|
||||
)
|
||||
test_db.add(waiting_period)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(waiting_period)
|
||||
return waiting_period
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_git_operation(test_db: AsyncSession, sample_session: Session) -> GitOperation:
|
||||
"""Create a sample git operation for testing."""
|
||||
git_operation = GitOperation(
|
||||
session_id=sample_session.id,
|
||||
operation="commit",
|
||||
command="git commit -m 'Add new feature'",
|
||||
result="[main 123abc] Add new feature",
|
||||
success=True,
|
||||
files_changed=["main.py", "utils.py"],
|
||||
lines_added=15,
|
||||
lines_removed=3,
|
||||
commit_hash="123abc456def"
|
||||
)
|
||||
test_db.add(git_operation)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(git_operation)
|
||||
return git_operation
|
||||
|
||||
# Faker fixtures for generating test data
|
||||
@pytest.fixture
|
||||
def fake():
|
||||
"""Faker instance for generating test data."""
|
||||
from faker import Faker
|
||||
return Faker()
|
||||
|
||||
@pytest.fixture
|
||||
def project_factory(fake):
|
||||
"""Factory for creating project test data."""
|
||||
def _create_project_data(**overrides):
|
||||
data = {
|
||||
"name": fake.company(),
|
||||
"path": fake.file_path(depth=3),
|
||||
"git_repo": fake.url(),
|
||||
"languages": fake.random_elements(
|
||||
elements=["python", "javascript", "typescript", "go", "rust"],
|
||||
length=fake.random_int(min=1, max=3),
|
||||
unique=True
|
||||
)
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
return _create_project_data
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(fake):
|
||||
"""Factory for creating session test data."""
|
||||
def _create_session_data(**overrides):
|
||||
data = {
|
||||
"session_type": fake.random_element(elements=["startup", "resume", "clear"]),
|
||||
"working_directory": fake.file_path(depth=3),
|
||||
"git_branch": fake.word(),
|
||||
"environment": {
|
||||
"user": fake.user_name(),
|
||||
"pwd": fake.file_path(depth=3),
|
||||
"timestamp": fake.iso8601()
|
||||
}
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
return _create_session_data
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_factory(fake):
|
||||
"""Factory for creating conversation test data."""
|
||||
def _create_conversation_data(**overrides):
|
||||
data = {
|
||||
"user_prompt": fake.sentence(nb_words=10),
|
||||
"claude_response": fake.paragraph(nb_sentences=3),
|
||||
"tools_used": fake.random_elements(
|
||||
elements=["Edit", "Write", "Read", "Bash", "Grep"],
|
||||
length=fake.random_int(min=1, max=3),
|
||||
unique=True
|
||||
),
|
||||
"files_affected": [fake.file_path() for _ in range(fake.random_int(min=0, max=3))],
|
||||
"exchange_type": fake.random_element(elements=["user_prompt", "claude_response"])
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
return _create_conversation_data
|
||||
|
||||
# Utility functions for tests
|
||||
@pytest.fixture
|
||||
def assert_response():
|
||||
"""Helper for asserting API response structure."""
|
||||
def _assert_response(response, status_code=200, required_keys=None):
|
||||
assert response.status_code == status_code
|
||||
if required_keys:
|
||||
data = response.json()
|
||||
for key in required_keys:
|
||||
assert key in data
|
||||
return response.json()
|
||||
return _assert_response
|
||||
|
||||
@pytest.fixture
|
||||
def create_test_data():
|
||||
"""Helper for creating test data in database."""
|
||||
async def _create_test_data(db: AsyncSession, model_class, count=1, **kwargs):
|
||||
items = []
|
||||
for i in range(count):
|
||||
item = model_class(**kwargs)
|
||||
db.add(item)
|
||||
items.append(item)
|
||||
await db.commit()
|
||||
for item in items:
|
||||
await db.refresh(item)
|
||||
return items[0] if count == 1 else items
|
||||
return _create_test_data
|
@ -1,413 +0,0 @@
|
||||
"""
|
||||
Test fixtures and sample data for the Claude Code Project Tracker.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
from faker import Faker
|
||||
|
||||
fake = Faker()
|
||||
|
||||
class TestDataFactory:
|
||||
"""Factory for creating realistic test data."""
|
||||
|
||||
@staticmethod
|
||||
def create_project_data(**overrides) -> Dict[str, Any]:
|
||||
"""Create sample project data."""
|
||||
data = {
|
||||
"name": fake.company(),
|
||||
"path": fake.file_path(depth=3, extension=""),
|
||||
"git_repo": fake.url().replace("http://", "https://github.com/"),
|
||||
"languages": fake.random_elements(
|
||||
elements=["python", "javascript", "typescript", "go", "rust", "java", "cpp"],
|
||||
length=fake.random_int(min=1, max=4),
|
||||
unique=True
|
||||
),
|
||||
"created_at": fake.date_time_between(start_date="-1y", end_date="now"),
|
||||
"last_session": fake.date_time_between(start_date="-30d", end_date="now"),
|
||||
"total_sessions": fake.random_int(min=1, max=50),
|
||||
"total_time_minutes": fake.random_int(min=30, max=2000),
|
||||
"files_modified_count": fake.random_int(min=5, max=100),
|
||||
"lines_changed_count": fake.random_int(min=100, max=5000)
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_session_data(project_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample session data."""
|
||||
start_time = fake.date_time_between(start_date="-7d", end_date="now")
|
||||
duration = fake.random_int(min=5, max=180) # 5 minutes to 3 hours
|
||||
|
||||
data = {
|
||||
"project_id": project_id,
|
||||
"start_time": start_time,
|
||||
"end_time": start_time + timedelta(minutes=duration),
|
||||
"session_type": fake.random_element(elements=["startup", "resume", "clear"]),
|
||||
"working_directory": fake.file_path(depth=3, extension=""),
|
||||
"git_branch": fake.random_element(elements=["main", "develop", "feature/new-feature", "bugfix/issue-123"]),
|
||||
"environment": {
|
||||
"user": fake.user_name(),
|
||||
"pwd": fake.file_path(depth=3, extension=""),
|
||||
"python_version": "3.11.0",
|
||||
"node_version": "18.17.0"
|
||||
},
|
||||
"duration_minutes": duration,
|
||||
"activity_count": fake.random_int(min=3, max=50),
|
||||
"conversation_count": fake.random_int(min=2, max=30),
|
||||
"files_touched": [fake.file_path() for _ in range(fake.random_int(min=1, max=8))]
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_conversation_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample conversation data."""
|
||||
prompts = [
|
||||
"How do I implement user authentication?",
|
||||
"Can you help me debug this error?",
|
||||
"What's the best way to structure this code?",
|
||||
"Help me optimize this function for performance",
|
||||
"How do I add tests for this component?",
|
||||
"Can you review this code for best practices?",
|
||||
"What libraries should I use for this feature?",
|
||||
"How do I handle errors in this async function?"
|
||||
]
|
||||
|
||||
responses = [
|
||||
"I can help you implement user authentication. Here's a comprehensive approach...",
|
||||
"Let me analyze this error and provide a solution...",
|
||||
"For code structure, I recommend following these patterns...",
|
||||
"Here are several optimization strategies for your function...",
|
||||
"Let's add comprehensive tests for this component...",
|
||||
"I've reviewed your code and here are my recommendations...",
|
||||
"For this feature, I suggest these well-maintained libraries...",
|
||||
"Here's how to properly handle errors in async functions..."
|
||||
]
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": fake.date_time_between(start_date="-7d", end_date="now"),
|
||||
"user_prompt": fake.random_element(elements=prompts),
|
||||
"claude_response": fake.random_element(elements=responses),
|
||||
"tools_used": fake.random_elements(
|
||||
elements=["Edit", "Write", "Read", "Bash", "Grep", "Glob", "Task"],
|
||||
length=fake.random_int(min=1, max=4),
|
||||
unique=True
|
||||
),
|
||||
"files_affected": [
|
||||
fake.file_path(extension=ext)
|
||||
for ext in fake.random_elements(
|
||||
elements=["py", "js", "ts", "go", "rs", "java", "cpp", "md"],
|
||||
length=fake.random_int(min=0, max=3),
|
||||
unique=True
|
||||
)
|
||||
],
|
||||
"context": {
|
||||
"intent": fake.random_element(elements=["debugging", "implementation", "learning", "optimization"]),
|
||||
"complexity": fake.random_element(elements=["low", "medium", "high"])
|
||||
},
|
||||
"tokens_input": fake.random_int(min=50, max=500),
|
||||
"tokens_output": fake.random_int(min=100, max=1000),
|
||||
"exchange_type": fake.random_element(elements=["user_prompt", "claude_response"])
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_activity_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample activity data."""
|
||||
tools = {
|
||||
"Edit": {
|
||||
"action": "file_edit",
|
||||
"metadata": {"lines_changed": fake.random_int(min=1, max=50)},
|
||||
"lines_added": fake.random_int(min=0, max=30),
|
||||
"lines_removed": fake.random_int(min=0, max=20)
|
||||
},
|
||||
"Write": {
|
||||
"action": "file_write",
|
||||
"metadata": {"new_file": fake.boolean()},
|
||||
"lines_added": fake.random_int(min=10, max=100),
|
||||
"lines_removed": 0
|
||||
},
|
||||
"Read": {
|
||||
"action": "file_read",
|
||||
"metadata": {"file_size": fake.random_int(min=100, max=5000)},
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
},
|
||||
"Bash": {
|
||||
"action": "command_execution",
|
||||
"metadata": {
|
||||
"command": fake.random_element(elements=[
|
||||
"npm install",
|
||||
"pytest",
|
||||
"git status",
|
||||
"python main.py",
|
||||
"docker build ."
|
||||
]),
|
||||
"exit_code": 0
|
||||
},
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
},
|
||||
"Grep": {
|
||||
"action": "search",
|
||||
"metadata": {
|
||||
"pattern": fake.word(),
|
||||
"matches_found": fake.random_int(min=0, max=20)
|
||||
},
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
}
|
||||
}
|
||||
|
||||
tool_name = fake.random_element(elements=list(tools.keys()))
|
||||
tool_config = tools[tool_name]
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": fake.date_time_between(start_date="-7d", end_date="now"),
|
||||
"tool_name": tool_name,
|
||||
"action": tool_config["action"],
|
||||
"file_path": fake.file_path() if tool_name in ["Edit", "Write", "Read"] else None,
|
||||
"metadata": tool_config["metadata"],
|
||||
"success": fake.boolean(chance_of_getting_true=90),
|
||||
"error_message": None if fake.boolean(chance_of_getting_true=90) else "Operation failed",
|
||||
"lines_added": tool_config.get("lines_added", 0),
|
||||
"lines_removed": tool_config.get("lines_removed", 0)
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_waiting_period_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample waiting period data."""
|
||||
start_time = fake.date_time_between(start_date="-7d", end_date="now")
|
||||
duration = fake.random_int(min=5, max=300) # 5 seconds to 5 minutes
|
||||
|
||||
activities = {
|
||||
"thinking": "User is contemplating the response",
|
||||
"research": "User is looking up documentation",
|
||||
"external_work": "User is working in another application",
|
||||
"break": "User stepped away from the computer"
|
||||
}
|
||||
|
||||
likely_activity = fake.random_element(elements=list(activities.keys()))
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"start_time": start_time,
|
||||
"end_time": start_time + timedelta(seconds=duration),
|
||||
"duration_seconds": duration,
|
||||
"context_before": "Claude finished providing a detailed explanation",
|
||||
"context_after": fake.random_element(elements=[
|
||||
"User asked a follow-up question",
|
||||
"User requested clarification",
|
||||
"User provided additional context",
|
||||
"User asked about a different topic"
|
||||
]),
|
||||
"likely_activity": likely_activity
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_git_operation_data(session_id: int = 1, **overrides) -> Dict[str, Any]:
|
||||
"""Create sample git operation data."""
|
||||
operations = {
|
||||
"commit": {
|
||||
"command": "git commit -m 'Add new feature'",
|
||||
"result": "[main abc123] Add new feature\\n 2 files changed, 15 insertions(+), 3 deletions(-)",
|
||||
"files_changed": ["main.py", "utils.py"],
|
||||
"lines_added": 15,
|
||||
"lines_removed": 3,
|
||||
"commit_hash": fake.sha1()[:8]
|
||||
},
|
||||
"push": {
|
||||
"command": "git push origin main",
|
||||
"result": "To https://github.com/user/repo.git\\n abc123..def456 main -> main",
|
||||
"files_changed": [],
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0,
|
||||
"commit_hash": None
|
||||
},
|
||||
"pull": {
|
||||
"command": "git pull origin main",
|
||||
"result": "Already up to date.",
|
||||
"files_changed": [],
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0,
|
||||
"commit_hash": None
|
||||
},
|
||||
"branch": {
|
||||
"command": "git checkout -b feature/new-feature",
|
||||
"result": "Switched to a new branch 'feature/new-feature'",
|
||||
"files_changed": [],
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0,
|
||||
"commit_hash": None
|
||||
}
|
||||
}
|
||||
|
||||
operation = fake.random_element(elements=list(operations.keys()))
|
||||
op_config = operations[operation]
|
||||
|
||||
data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": fake.date_time_between(start_date="-7d", end_date="now"),
|
||||
"operation": operation,
|
||||
"command": op_config["command"],
|
||||
"result": op_config["result"],
|
||||
"success": fake.boolean(chance_of_getting_true=95),
|
||||
"files_changed": op_config["files_changed"],
|
||||
"lines_added": op_config["lines_added"],
|
||||
"lines_removed": op_config["lines_removed"],
|
||||
"commit_hash": op_config["commit_hash"],
|
||||
"branch_from": fake.random_element(elements=["main", "develop", "feature/old-feature"]) if operation == "merge" else None,
|
||||
"branch_to": fake.random_element(elements=["main", "develop"]) if operation == "merge" else None
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
class SampleDataSets:
|
||||
"""Pre-defined sample data sets for different test scenarios."""
|
||||
|
||||
@staticmethod
|
||||
def productive_session() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Data for a highly productive development session."""
|
||||
project_data = TestDataFactory.create_project_data(
|
||||
name="E-commerce Platform",
|
||||
languages=["python", "javascript", "typescript"],
|
||||
total_sessions=25,
|
||||
total_time_minutes=800
|
||||
)
|
||||
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
session_type="startup",
|
||||
duration_minutes=120,
|
||||
activity_count=45,
|
||||
conversation_count=12
|
||||
)
|
||||
|
||||
conversations = [
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="How do I implement user authentication with JWT?",
|
||||
tools_used=["Edit", "Write"],
|
||||
files_affected=["auth.py", "models.py"]
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="Can you help me optimize this database query?",
|
||||
tools_used=["Edit", "Read"],
|
||||
files_affected=["queries.py"]
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="What's the best way to handle async errors?",
|
||||
tools_used=["Edit"],
|
||||
files_affected=["error_handler.py"]
|
||||
)
|
||||
]
|
||||
|
||||
activities = [
|
||||
TestDataFactory.create_activity_data(tool_name="Edit", lines_added=25, lines_removed=5),
|
||||
TestDataFactory.create_activity_data(tool_name="Write", lines_added=50, lines_removed=0),
|
||||
TestDataFactory.create_activity_data(tool_name="Bash", metadata={"command": "pytest"}),
|
||||
TestDataFactory.create_activity_data(tool_name="Read", file_path="/docs/api.md")
|
||||
]
|
||||
|
||||
return {
|
||||
"project": project_data,
|
||||
"session": session_data,
|
||||
"conversations": conversations,
|
||||
"activities": activities
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def debugging_session() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Data for a debugging-focused session with lots of investigation."""
|
||||
project_data = TestDataFactory.create_project_data(
|
||||
name="Bug Tracking System",
|
||||
languages=["go", "typescript"]
|
||||
)
|
||||
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
session_type="resume",
|
||||
duration_minutes=90,
|
||||
activity_count=35,
|
||||
conversation_count=8
|
||||
)
|
||||
|
||||
conversations = [
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="I'm getting a panic in my Go application, can you help debug?",
|
||||
tools_used=["Read", "Grep"],
|
||||
files_affected=["main.go", "handler.go"],
|
||||
context={"intent": "debugging", "complexity": "high"}
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="The tests are failing intermittently, what could be wrong?",
|
||||
tools_used=["Read", "Bash"],
|
||||
files_affected=["test_handler.go"]
|
||||
)
|
||||
]
|
||||
|
||||
# Lots of read operations for debugging
|
||||
activities = [
|
||||
TestDataFactory.create_activity_data(tool_name="Read") for _ in range(8)
|
||||
] + [
|
||||
TestDataFactory.create_activity_data(tool_name="Grep", metadata={"pattern": "error", "matches_found": 12}),
|
||||
TestDataFactory.create_activity_data(tool_name="Bash", metadata={"command": "go test -v"}),
|
||||
TestDataFactory.create_activity_data(tool_name="Edit", lines_added=3, lines_removed=1)
|
||||
]
|
||||
|
||||
# Longer thinking periods during debugging
|
||||
waiting_periods = [
|
||||
TestDataFactory.create_waiting_period_data(
|
||||
duration_seconds=180,
|
||||
likely_activity="research",
|
||||
context_after="User asked about error patterns"
|
||||
),
|
||||
TestDataFactory.create_waiting_period_data(
|
||||
duration_seconds=120,
|
||||
likely_activity="thinking",
|
||||
context_after="User provided stack trace"
|
||||
)
|
||||
]
|
||||
|
||||
return {
|
||||
"project": project_data,
|
||||
"session": session_data,
|
||||
"conversations": conversations,
|
||||
"activities": activities,
|
||||
"waiting_periods": waiting_periods
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def learning_session() -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Data for a learning-focused session with lots of questions."""
|
||||
project_data = TestDataFactory.create_project_data(
|
||||
name="Learning Rust",
|
||||
languages=["rust"],
|
||||
total_sessions=5,
|
||||
total_time_minutes=200
|
||||
)
|
||||
|
||||
conversations = [
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="What's the difference between String and &str in Rust?",
|
||||
context={"intent": "learning", "complexity": "medium"}
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="How do I handle ownership and borrowing correctly?",
|
||||
context={"intent": "learning", "complexity": "high"}
|
||||
),
|
||||
TestDataFactory.create_conversation_data(
|
||||
user_prompt="Can you explain Rust's trait system?",
|
||||
context={"intent": "learning", "complexity": "high"}
|
||||
)
|
||||
]
|
||||
|
||||
return {
|
||||
"project": project_data,
|
||||
"conversations": conversations
|
||||
}
|
@ -1,525 +0,0 @@
|
||||
"""
|
||||
Tests for the Claude Code Project Tracker API endpoints.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from tests.fixtures import TestDataFactory
|
||||
|
||||
|
||||
class TestSessionAPI:
|
||||
"""Test session management endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_session_startup(self, test_client: AsyncClient):
|
||||
"""Test starting a new session with startup type."""
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
session_type="startup",
|
||||
working_directory="/home/user/test-project",
|
||||
git_branch="main"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/session/start", json=session_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "session_id" in data
|
||||
assert "project_id" in data
|
||||
assert data["status"] == "started"
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_session_creates_project(self, test_client: AsyncClient):
|
||||
"""Test that starting a session creates a project if it doesn't exist."""
|
||||
session_data = {
|
||||
"session_type": "startup",
|
||||
"working_directory": "/home/user/new-project",
|
||||
"git_repo": "https://github.com/user/new-project",
|
||||
"environment": {"user": "testuser"}
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/session/start", json=session_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["project_id"] is not None
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_session_resume(self, test_client: AsyncClient):
|
||||
"""Test resuming an existing session."""
|
||||
session_data = TestDataFactory.create_session_data(session_type="resume")
|
||||
|
||||
response = await test_client.post("/api/session/start", json=session_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "session_id" in data
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_end_session(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test ending an active session."""
|
||||
end_data = {
|
||||
"session_id": sample_session.id,
|
||||
"end_reason": "normal"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/session/end", json=end_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["session_id"] == sample_session.id
|
||||
assert data["status"] == "ended"
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_end_nonexistent_session(self, test_client: AsyncClient):
|
||||
"""Test ending a session that doesn't exist."""
|
||||
end_data = {
|
||||
"session_id": 99999,
|
||||
"end_reason": "normal"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/session/end", json=end_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestConversationAPI:
|
||||
"""Test conversation tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_log_conversation(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test logging a conversation exchange."""
|
||||
conversation_data = TestDataFactory.create_conversation_data(
|
||||
session_id=sample_session.id,
|
||||
user_prompt="How do I implement authentication?",
|
||||
claude_response="You can implement authentication using...",
|
||||
tools_used=["Edit", "Write"]
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_log_conversation_user_prompt_only(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test logging just a user prompt."""
|
||||
conversation_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"user_prompt": "How do I fix this error?",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_log_conversation_invalid_session(self, test_client: AsyncClient):
|
||||
"""Test logging conversation with invalid session ID."""
|
||||
conversation_data = TestDataFactory.create_conversation_data(session_id=99999)
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestActivityAPI:
|
||||
"""Test activity tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_activity_edit(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording an Edit tool activity."""
|
||||
activity_data = TestDataFactory.create_activity_data(
|
||||
session_id=sample_session.id,
|
||||
tool_name="Edit",
|
||||
action="file_edit",
|
||||
file_path="/home/user/test.py",
|
||||
lines_added=10,
|
||||
lines_removed=3
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/activity", json=activity_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_activity_bash(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a Bash command activity."""
|
||||
activity_data = TestDataFactory.create_activity_data(
|
||||
session_id=sample_session.id,
|
||||
tool_name="Bash",
|
||||
action="command_execution",
|
||||
metadata={"command": "pytest", "exit_code": 0}
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/activity", json=activity_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_activity_failed(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a failed activity."""
|
||||
activity_data = TestDataFactory.create_activity_data(
|
||||
session_id=sample_session.id,
|
||||
success=False,
|
||||
error_message="Permission denied"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/activity", json=activity_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestWaitingAPI:
|
||||
"""Test waiting period tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_start_waiting_period(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test starting a waiting period."""
|
||||
waiting_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"context_before": "Claude finished responding"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/waiting/start", json=waiting_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_end_waiting_period(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test ending a waiting period."""
|
||||
# First start a waiting period
|
||||
start_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"context_before": "Claude finished responding"
|
||||
}
|
||||
await test_client.post("/api/waiting/start", json=start_data)
|
||||
|
||||
# Then end it
|
||||
end_data = {
|
||||
"session_id": sample_session.id,
|
||||
"timestamp": "2024-01-01T12:05:00Z",
|
||||
"duration_seconds": 300,
|
||||
"context_after": "User asked follow-up question"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/waiting/end", json=end_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGitAPI:
|
||||
"""Test git operation tracking endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_git_commit(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a git commit operation."""
|
||||
git_data = TestDataFactory.create_git_operation_data(
|
||||
session_id=sample_session.id,
|
||||
operation="commit",
|
||||
command="git commit -m 'Add feature'",
|
||||
files_changed=["main.py", "utils.py"],
|
||||
lines_added=20,
|
||||
lines_removed=5
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/git", json=git_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_record_git_push(self, test_client: AsyncClient, sample_session: Session):
|
||||
"""Test recording a git push operation."""
|
||||
git_data = TestDataFactory.create_git_operation_data(
|
||||
session_id=sample_session.id,
|
||||
operation="push",
|
||||
command="git push origin main"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/git", json=git_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestProjectAPI:
|
||||
"""Test project data retrieval endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_list_projects(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test listing all projects."""
|
||||
response = await test_client.get("/api/projects")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
|
||||
project = data[0]
|
||||
assert "id" in project
|
||||
assert "name" in project
|
||||
assert "path" in project
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_list_projects_with_pagination(self, test_client: AsyncClient):
|
||||
"""Test project listing with pagination parameters."""
|
||||
response = await test_client.get("/api/projects?limit=5&offset=0")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) <= 5
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_project_timeline(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test getting detailed project timeline."""
|
||||
response = await test_client.get(f"/api/projects/{sample_project.id}/timeline")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "project" in data
|
||||
assert "timeline" in data
|
||||
assert isinstance(data["timeline"], list)
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_nonexistent_project_timeline(self, test_client: AsyncClient):
|
||||
"""Test getting timeline for non-existent project."""
|
||||
response = await test_client.get("/api/projects/99999/timeline")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAnalyticsAPI:
|
||||
"""Test analytics and insights endpoints."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_productivity_metrics(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test getting productivity analytics."""
|
||||
response = await test_client.get("/api/analytics/productivity")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "engagement_score" in data
|
||||
assert "average_session_length" in data
|
||||
assert "think_time_average" in data
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_get_productivity_metrics_for_project(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test getting productivity analytics for specific project."""
|
||||
response = await test_client.get(f"/api/analytics/productivity?project_id={sample_project.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_search_conversations(self, test_client: AsyncClient, sample_conversation):
|
||||
"""Test searching through conversations."""
|
||||
response = await test_client.get("/api/conversations/search?query=implement feature")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
if data: # If there are results
|
||||
result = data[0]
|
||||
assert "id" in result
|
||||
assert "user_prompt" in result
|
||||
assert "relevance_score" in result
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_search_conversations_with_project_filter(self, test_client: AsyncClient, sample_project: Project):
|
||||
"""Test searching conversations within a specific project."""
|
||||
response = await test_client.get(f"/api/conversations/search?query=debug&project_id={sample_project.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling and edge cases."""
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_malformed_json(self, test_client: AsyncClient):
|
||||
"""Test handling of malformed JSON requests."""
|
||||
response = await test_client.post(
|
||||
"/api/session/start",
|
||||
content="{'invalid': json}",
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_missing_required_fields(self, test_client: AsyncClient):
|
||||
"""Test handling of requests with missing required fields."""
|
||||
response = await test_client.post("/api/session/start", json={})
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_invalid_session_id_type(self, test_client: AsyncClient):
|
||||
"""Test handling of invalid data types."""
|
||||
conversation_data = {
|
||||
"session_id": "invalid", # Should be int
|
||||
"user_prompt": "Test message",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/conversation", json=conversation_data)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.api
|
||||
async def test_nonexistent_endpoint(self, test_client: AsyncClient):
|
||||
"""Test accessing non-existent endpoint."""
|
||||
response = await test_client.get("/api/nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Test complete workflow scenarios."""
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_complete_session_workflow(self, test_client: AsyncClient):
|
||||
"""Test a complete session from start to finish."""
|
||||
# Start session
|
||||
session_data = TestDataFactory.create_session_data(
|
||||
working_directory="/home/user/integration-test"
|
||||
)
|
||||
|
||||
start_response = await test_client.post("/api/session/start", json=session_data)
|
||||
assert start_response.status_code == 201
|
||||
session_info = start_response.json()
|
||||
session_id = session_info["session_id"]
|
||||
|
||||
# Log user prompt
|
||||
prompt_data = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"user_prompt": "Help me implement a REST API",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
prompt_response = await test_client.post("/api/conversation", json=prompt_data)
|
||||
assert prompt_response.status_code == 201
|
||||
|
||||
# Start waiting period
|
||||
waiting_start = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:00:01Z",
|
||||
"context_before": "Claude is processing the request"
|
||||
}
|
||||
|
||||
waiting_response = await test_client.post("/api/waiting/start", json=waiting_start)
|
||||
assert waiting_response.status_code == 201
|
||||
|
||||
# Record some activities
|
||||
activities = [
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tool_name": "Write",
|
||||
"action": "file_write",
|
||||
"file_path": "/home/user/integration-test/api.py",
|
||||
"timestamp": "2024-01-01T12:01:00Z",
|
||||
"success": True,
|
||||
"lines_added": 50
|
||||
},
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tool_name": "Edit",
|
||||
"action": "file_edit",
|
||||
"file_path": "/home/user/integration-test/main.py",
|
||||
"timestamp": "2024-01-01T12:02:00Z",
|
||||
"success": True,
|
||||
"lines_added": 10,
|
||||
"lines_removed": 2
|
||||
}
|
||||
]
|
||||
|
||||
for activity in activities:
|
||||
activity_response = await test_client.post("/api/activity", json=activity)
|
||||
assert activity_response.status_code == 201
|
||||
|
||||
# End waiting period
|
||||
waiting_end = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:05:00Z",
|
||||
"duration_seconds": 300,
|
||||
"context_after": "User reviewed the implementation"
|
||||
}
|
||||
|
||||
waiting_end_response = await test_client.post("/api/waiting/end", json=waiting_end)
|
||||
assert waiting_end_response.status_code == 200
|
||||
|
||||
# Record git commit
|
||||
git_data = {
|
||||
"session_id": session_id,
|
||||
"operation": "commit",
|
||||
"command": "git commit -m 'Implement REST API'",
|
||||
"timestamp": "2024-01-01T12:06:00Z",
|
||||
"result": "[main abc123] Implement REST API",
|
||||
"success": True,
|
||||
"files_changed": ["api.py", "main.py"],
|
||||
"lines_added": 60,
|
||||
"lines_removed": 2,
|
||||
"commit_hash": "abc12345"
|
||||
}
|
||||
|
||||
git_response = await test_client.post("/api/git", json=git_data)
|
||||
assert git_response.status_code == 201
|
||||
|
||||
# End session
|
||||
end_data = {
|
||||
"session_id": session_id,
|
||||
"end_reason": "normal"
|
||||
}
|
||||
|
||||
end_response = await test_client.post("/api/session/end", json=end_data)
|
||||
assert end_response.status_code == 200
|
||||
|
||||
# Verify project was created and populated
|
||||
projects_response = await test_client.get("/api/projects")
|
||||
assert projects_response.status_code == 200
|
||||
projects = projects_response.json()
|
||||
|
||||
# Find our project
|
||||
test_project = None
|
||||
for project in projects:
|
||||
if project["path"] == "/home/user/integration-test":
|
||||
test_project = project
|
||||
break
|
||||
|
||||
assert test_project is not None
|
||||
assert test_project["total_sessions"] >= 1
|
||||
|
||||
# Get project timeline
|
||||
timeline_response = await test_client.get(f"/api/projects/{test_project['id']}/timeline")
|
||||
assert timeline_response.status_code == 200
|
||||
timeline = timeline_response.json()
|
||||
|
||||
assert len(timeline["timeline"]) > 0 # Should have recorded events
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.slow
|
||||
async def test_analytics_with_sample_data(self, test_client: AsyncClient, test_db: AsyncSession):
|
||||
"""Test analytics calculations with realistic sample data."""
|
||||
# This test would create a full dataset and verify analytics work correctly
|
||||
# Marked as slow since it involves more data processing
|
||||
|
||||
productivity_response = await test_client.get("/api/analytics/productivity?days=7")
|
||||
assert productivity_response.status_code == 200
|
||||
|
||||
metrics = productivity_response.json()
|
||||
assert "engagement_score" in metrics
|
||||
assert isinstance(metrics["engagement_score"], (int, float))
|
||||
assert 0 <= metrics["engagement_score"] <= 100
|
@ -1,469 +0,0 @@
|
||||
"""
|
||||
Tests for Claude Code hook simulation and integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.fixtures import TestDataFactory
|
||||
|
||||
|
||||
class TestHookSimulation:
|
||||
"""Test hook payload generation and API integration."""
|
||||
|
||||
def test_session_start_hook_payload(self):
|
||||
"""Test generating SessionStart hook payload."""
|
||||
# Simulate environment variables that would be set by Claude Code
|
||||
mock_env = {
|
||||
"PWD": "/home/user/test-project",
|
||||
"USER": "testuser"
|
||||
}
|
||||
|
||||
with patch.dict("os.environ", mock_env):
|
||||
with patch("subprocess.check_output") as mock_git:
|
||||
# Mock git commands
|
||||
mock_git.side_effect = [
|
||||
b"main\n", # git branch --show-current
|
||||
b"https://github.com/user/test-project.git\n" # git config --get remote.origin.url
|
||||
]
|
||||
|
||||
payload = {
|
||||
"session_type": "startup",
|
||||
"working_directory": mock_env["PWD"],
|
||||
"git_branch": "main",
|
||||
"git_repo": "https://github.com/user/test-project.git",
|
||||
"environment": {
|
||||
"pwd": mock_env["PWD"],
|
||||
"user": mock_env["USER"],
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
# Verify payload structure matches API expectations
|
||||
assert "session_type" in payload
|
||||
assert "working_directory" in payload
|
||||
assert payload["session_type"] in ["startup", "resume", "clear"]
|
||||
|
||||
def test_user_prompt_hook_payload(self):
|
||||
"""Test generating UserPromptSubmit hook payload."""
|
||||
mock_prompt = "How do I implement user authentication?"
|
||||
mock_session_id = "123"
|
||||
|
||||
payload = {
|
||||
"session_id": int(mock_session_id),
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"user_prompt": mock_prompt,
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
assert payload["session_id"] == 123
|
||||
assert payload["user_prompt"] == mock_prompt
|
||||
assert payload["exchange_type"] == "user_prompt"
|
||||
|
||||
def test_post_tool_use_edit_payload(self):
|
||||
"""Test generating PostToolUse Edit hook payload."""
|
||||
mock_file_path = "/home/user/test-project/main.py"
|
||||
mock_session_id = "123"
|
||||
|
||||
payload = {
|
||||
"session_id": int(mock_session_id),
|
||||
"tool_name": "Edit",
|
||||
"action": "file_edit",
|
||||
"file_path": mock_file_path,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"metadata": {"success": True},
|
||||
"success": True
|
||||
}
|
||||
|
||||
assert payload["tool_name"] == "Edit"
|
||||
assert payload["file_path"] == mock_file_path
|
||||
assert payload["success"] is True
|
||||
|
||||
def test_post_tool_use_bash_payload(self):
|
||||
"""Test generating PostToolUse Bash hook payload."""
|
||||
mock_command = "pytest --cov=app"
|
||||
mock_session_id = "123"
|
||||
|
||||
payload = {
|
||||
"session_id": int(mock_session_id),
|
||||
"tool_name": "Bash",
|
||||
"action": "command_execution",
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"metadata": {
|
||||
"command": mock_command,
|
||||
"success": True
|
||||
},
|
||||
"success": True
|
||||
}
|
||||
|
||||
assert payload["tool_name"] == "Bash"
|
||||
assert payload["metadata"]["command"] == mock_command
|
||||
|
||||
def test_notification_hook_payload(self):
|
||||
"""Test generating Notification hook payload."""
|
||||
mock_session_id = "123"
|
||||
|
||||
payload = {
|
||||
"session_id": int(mock_session_id),
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"context_before": "Claude is waiting for input"
|
||||
}
|
||||
|
||||
assert payload["session_id"] == 123
|
||||
assert "context_before" in payload
|
||||
|
||||
def test_stop_hook_payload(self):
|
||||
"""Test generating Stop hook payload."""
|
||||
mock_session_id = "123"
|
||||
|
||||
payload = {
|
||||
"session_id": int(mock_session_id),
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"claude_response": "Response completed",
|
||||
"exchange_type": "claude_response"
|
||||
}
|
||||
|
||||
assert payload["exchange_type"] == "claude_response"
|
||||
|
||||
def test_json_escaping_in_payloads(self):
|
||||
"""Test that special characters in payloads are properly escaped."""
|
||||
# Test prompt with quotes and newlines
|
||||
problematic_prompt = 'How do I handle "quotes" and\nnewlines in JSON?'
|
||||
|
||||
payload = {
|
||||
"session_id": 1,
|
||||
"user_prompt": problematic_prompt,
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
# Should be able to serialize to JSON without errors
|
||||
json_str = json.dumps(payload)
|
||||
parsed = json.loads(json_str)
|
||||
|
||||
assert parsed["user_prompt"] == problematic_prompt
|
||||
|
||||
def test_file_path_escaping(self):
|
||||
"""Test that file paths with spaces are handled correctly."""
|
||||
file_path_with_spaces = "/home/user/My Projects/test project/main.py"
|
||||
|
||||
payload = {
|
||||
"session_id": 1,
|
||||
"tool_name": "Edit",
|
||||
"file_path": file_path_with_spaces
|
||||
}
|
||||
|
||||
json_str = json.dumps(payload)
|
||||
parsed = json.loads(json_str)
|
||||
|
||||
assert parsed["file_path"] == file_path_with_spaces
|
||||
|
||||
|
||||
class TestHookIntegration:
|
||||
"""Test actual hook integration with the API."""
|
||||
|
||||
@pytest.mark.hooks
|
||||
async def test_session_start_hook_integration(self, test_client: AsyncClient):
|
||||
"""Test complete SessionStart hook workflow."""
|
||||
# Simulate the payload that would be sent by the hook
|
||||
hook_payload = TestDataFactory.create_session_data(
|
||||
session_type="startup",
|
||||
working_directory="/home/user/hook-test",
|
||||
git_branch="main",
|
||||
git_repo="https://github.com/user/hook-test.git"
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/session/start", json=hook_payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "session_id" in data
|
||||
assert "project_id" in data
|
||||
|
||||
# Store session ID for subsequent hook calls
|
||||
return data["session_id"]
|
||||
|
||||
@pytest.mark.hooks
|
||||
async def test_user_prompt_hook_integration(self, test_client: AsyncClient):
|
||||
"""Test UserPromptSubmit hook integration."""
|
||||
# First create a session
|
||||
session_id = await self.test_session_start_hook_integration(test_client)
|
||||
|
||||
# Simulate user prompt hook
|
||||
hook_payload = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"user_prompt": "This prompt came from a hook",
|
||||
"exchange_type": "user_prompt"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/conversation", json=hook_payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.hooks
|
||||
async def test_post_tool_use_hook_integration(self, test_client: AsyncClient):
|
||||
"""Test PostToolUse hook integration."""
|
||||
session_id = await self.test_session_start_hook_integration(test_client)
|
||||
|
||||
# Simulate Edit tool hook
|
||||
hook_payload = {
|
||||
"session_id": session_id,
|
||||
"tool_name": "Edit",
|
||||
"action": "file_edit",
|
||||
"file_path": "/home/user/hook-test/main.py",
|
||||
"timestamp": "2024-01-01T12:01:00Z",
|
||||
"metadata": {"lines_changed": 5},
|
||||
"success": True,
|
||||
"lines_added": 3,
|
||||
"lines_removed": 2
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/activity", json=hook_payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.hooks
|
||||
async def test_waiting_period_hook_integration(self, test_client: AsyncClient):
|
||||
"""Test Notification and waiting period hooks."""
|
||||
session_id = await self.test_session_start_hook_integration(test_client)
|
||||
|
||||
# Start waiting period
|
||||
start_payload = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"context_before": "Claude finished responding"
|
||||
}
|
||||
|
||||
start_response = await test_client.post("/api/waiting/start", json=start_payload)
|
||||
assert start_response.status_code == 201
|
||||
|
||||
# End waiting period
|
||||
end_payload = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:05:00Z",
|
||||
"duration_seconds": 300,
|
||||
"context_after": "User submitted new prompt"
|
||||
}
|
||||
|
||||
end_response = await test_client.post("/api/waiting/end", json=end_payload)
|
||||
assert end_response.status_code == 200
|
||||
|
||||
@pytest.mark.hooks
|
||||
async def test_stop_hook_integration(self, test_client: AsyncClient):
|
||||
"""Test Stop hook integration."""
|
||||
session_id = await self.test_session_start_hook_integration(test_client)
|
||||
|
||||
# Simulate Stop hook (Claude response)
|
||||
hook_payload = {
|
||||
"session_id": session_id,
|
||||
"timestamp": "2024-01-01T12:10:00Z",
|
||||
"claude_response": "Here's the implementation you requested...",
|
||||
"tools_used": ["Edit", "Write"],
|
||||
"files_affected": ["main.py", "utils.py"],
|
||||
"exchange_type": "claude_response"
|
||||
}
|
||||
|
||||
response = await test_client.post("/api/conversation", json=hook_payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.hooks
|
||||
async def test_git_hook_integration(self, test_client: AsyncClient):
|
||||
"""Test git operation hook integration."""
|
||||
session_id = await self.test_session_start_hook_integration(test_client)
|
||||
|
||||
hook_payload = TestDataFactory.create_git_operation_data(
|
||||
session_id=session_id,
|
||||
operation="commit",
|
||||
command="git commit -m 'Test commit from hook'",
|
||||
files_changed=["main.py"],
|
||||
lines_added=10,
|
||||
lines_removed=2
|
||||
)
|
||||
|
||||
response = await test_client.post("/api/git", json=hook_payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestHookEnvironment:
|
||||
"""Test hook environment and configuration."""
|
||||
|
||||
def test_session_id_persistence(self):
|
||||
"""Test session ID storage and retrieval mechanism."""
|
||||
session_file = "/tmp/claude-session-id"
|
||||
test_session_id = "12345"
|
||||
|
||||
# Simulate writing session ID to file (as hooks would do)
|
||||
with open(session_file, "w") as f:
|
||||
f.write(test_session_id)
|
||||
|
||||
# Simulate reading session ID from file (as subsequent hooks would do)
|
||||
with open(session_file, "r") as f:
|
||||
retrieved_id = f.read().strip()
|
||||
|
||||
assert retrieved_id == test_session_id
|
||||
|
||||
# Clean up
|
||||
import os
|
||||
if os.path.exists(session_file):
|
||||
os.remove(session_file)
|
||||
|
||||
def test_git_environment_detection(self):
|
||||
"""Test git repository detection logic."""
|
||||
with patch("subprocess.check_output") as mock_subprocess:
|
||||
# Mock successful git commands
|
||||
mock_subprocess.side_effect = [
|
||||
b"main\n", # git branch --show-current
|
||||
b"https://github.com/user/repo.git\n" # git config --get remote.origin.url
|
||||
]
|
||||
|
||||
# Simulate what hooks would do
|
||||
try:
|
||||
branch = subprocess.check_output(
|
||||
["git", "branch", "--show-current"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True
|
||||
).strip()
|
||||
|
||||
repo = subprocess.check_output(
|
||||
["git", "config", "--get", "remote.origin.url"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True
|
||||
).strip()
|
||||
|
||||
assert branch == "main"
|
||||
assert repo == "https://github.com/user/repo.git"
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
# If git commands fail, hooks should handle gracefully
|
||||
branch = "unknown"
|
||||
repo = "null"
|
||||
|
||||
# Test non-git directory handling
|
||||
with patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(128, "git")):
|
||||
try:
|
||||
branch = subprocess.check_output(
|
||||
["git", "branch", "--show-current"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
branch = "unknown"
|
||||
|
||||
assert branch == "unknown"
|
||||
|
||||
def test_hook_error_handling(self):
|
||||
"""Test hook error handling and graceful failures."""
|
||||
# Test network failure (API unreachable)
|
||||
import requests
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("requests.post", side_effect=requests.exceptions.ConnectionError("Connection refused")):
|
||||
# Hooks should not crash if API is unreachable
|
||||
# This would be handled by curl in the actual hooks with > /dev/null 2>&1
|
||||
try:
|
||||
# Simulate what would happen in a hook
|
||||
response = requests.post("http://localhost:8000/api/session/start", json={})
|
||||
assert False, "Should have raised ConnectionError"
|
||||
except requests.exceptions.ConnectionError:
|
||||
# This is expected - hook should handle gracefully
|
||||
pass
|
||||
|
||||
def test_hook_command_construction(self):
|
||||
"""Test building hook commands with proper escaping."""
|
||||
# Test session start hook command construction
|
||||
session_data = {
|
||||
"session_type": "startup",
|
||||
"working_directory": "/home/user/test project", # Space in path
|
||||
"git_branch": "feature/new-feature",
|
||||
"environment": {"user": "testuser"}
|
||||
}
|
||||
|
||||
# Construct JSON payload like the hook would
|
||||
json_payload = json.dumps(session_data)
|
||||
|
||||
# Verify it can be parsed back
|
||||
parsed = json.loads(json_payload)
|
||||
assert parsed["working_directory"] == "/home/user/test project"
|
||||
|
||||
def test_hook_timing_and_ordering(self):
|
||||
"""Test that hook timing and ordering work correctly."""
|
||||
# Simulate rapid-fire hook calls (as would happen during active development)
|
||||
timestamps = [
|
||||
"2024-01-01T12:00:00Z", # Session start
|
||||
"2024-01-01T12:00:01Z", # User prompt
|
||||
"2024-01-01T12:00:02Z", # Waiting start
|
||||
"2024-01-01T12:00:05Z", # Tool use (Edit)
|
||||
"2024-01-01T12:00:06Z", # Tool use (Write)
|
||||
"2024-01-01T12:00:10Z", # Waiting end
|
||||
"2024-01-01T12:00:11Z", # Claude response
|
||||
]
|
||||
|
||||
# Verify timestamps are in chronological order
|
||||
for i in range(1, len(timestamps)):
|
||||
assert timestamps[i] > timestamps[i-1]
|
||||
|
||||
|
||||
class TestHookConfiguration:
|
||||
"""Test hook configuration validation and setup."""
|
||||
|
||||
def test_hook_config_validation(self):
|
||||
"""Test validation of hook configuration JSON."""
|
||||
# Load and validate the provided hook configuration
|
||||
with open("config/claude-hooks.json", "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Verify required hook types are present
|
||||
required_hooks = ["SessionStart", "UserPromptSubmit", "PostToolUse", "Notification", "Stop"]
|
||||
for hook_type in required_hooks:
|
||||
assert hook_type in config["hooks"]
|
||||
|
||||
# Verify SessionStart has all session types
|
||||
session_start_hooks = config["hooks"]["SessionStart"]
|
||||
matchers = [hook.get("matcher") for hook in session_start_hooks if "matcher" in hook]
|
||||
assert "startup" in matchers
|
||||
assert "resume" in matchers
|
||||
assert "clear" in matchers
|
||||
|
||||
# Verify PostToolUse has main tools
|
||||
post_tool_hooks = config["hooks"]["PostToolUse"]
|
||||
tool_matchers = [hook.get("matcher") for hook in post_tool_hooks if "matcher" in hook]
|
||||
assert "Edit" in tool_matchers
|
||||
assert "Write" in tool_matchers
|
||||
assert "Read" in tool_matchers
|
||||
assert "Bash" in tool_matchers
|
||||
|
||||
def test_hook_command_structure(self):
|
||||
"""Test that hook commands have proper structure."""
|
||||
with open("config/claude-hooks.json", "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
for hook_type, hooks in config["hooks"].items():
|
||||
for hook in hooks:
|
||||
assert "command" in hook
|
||||
command = hook["command"]
|
||||
|
||||
# All commands should make HTTP requests to localhost:8000
|
||||
assert "http://localhost:8000" in command
|
||||
assert "curl" in command
|
||||
|
||||
# Commands should run in background and suppress output
|
||||
assert "> /dev/null 2>&1 &" in command
|
||||
|
||||
def test_session_id_handling_in_config(self):
|
||||
"""Test that hook configuration properly handles session ID."""
|
||||
with open("config/claude-hooks.json", "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Non-SessionStart hooks should use session ID from temp file
|
||||
for hook_type, hooks in config["hooks"].items():
|
||||
if hook_type != "SessionStart":
|
||||
for hook in hooks:
|
||||
command = hook["command"]
|
||||
# Should reference the session ID temp file
|
||||
assert "CLAUDE_SESSION_FILE" in command
|
||||
assert "/tmp/claude-session-id" in command
|
Loading…
x
Reference in New Issue
Block a user