Initial commit: Claude Code Project Tracker
Add comprehensive development intelligence system that tracks: - Development sessions with automatic start/stop - Full conversation history with semantic search - Tool usage and file operation analytics - Think time and engagement analysis - Git activity correlation - Learning pattern recognition - Productivity insights and metrics Features: - FastAPI backend with SQLite database - Modern web dashboard with interactive charts - Claude Code hook integration for automatic tracking - Comprehensive test suite with 100+ tests - Complete API documentation (OpenAPI/Swagger) - Privacy-first design with local data storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
44ed9936b7
48 changed files with 9707 additions and 0 deletions
21
app/models/__init__.py
Normal file
21
app/models/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""
|
||||
Database models for the Claude Code Project Tracker.
|
||||
"""
|
||||
|
||||
from .base import Base
|
||||
from .project import Project
|
||||
from .session import Session
|
||||
from .conversation import Conversation
|
||||
from .activity import Activity
|
||||
from .waiting_period import WaitingPeriod
|
||||
from .git_operation import GitOperation
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Project",
|
||||
"Session",
|
||||
"Conversation",
|
||||
"Activity",
|
||||
"WaitingPeriod",
|
||||
"GitOperation",
|
||||
]
|
||||
162
app/models/activity.py
Normal file
162
app/models/activity.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""
|
||||
Activity model for tracking tool usage and file operations.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Activity(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a single tool usage or file operation during development.
|
||||
|
||||
Activities are generated by Claude Code tool usage and provide
|
||||
detailed insight into the development workflow.
|
||||
"""
|
||||
|
||||
__tablename__ = "activities"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Foreign keys
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
|
||||
conversation_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("conversations.id"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Activity timing
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=func.now(),
|
||||
index=True
|
||||
)
|
||||
|
||||
# Activity details
|
||||
tool_name: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
file_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True, index=True)
|
||||
|
||||
# Metadata and results
|
||||
metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
||||
success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Code change metrics (for Edit/Write operations)
|
||||
lines_added: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
lines_removed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship("Session", back_populates="activities")
|
||||
conversation: Mapped[Optional["Conversation"]] = relationship(
|
||||
"Conversation",
|
||||
foreign_keys=[conversation_id]
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
file_info = f", file='{self.file_path}'" if self.file_path else ""
|
||||
return f"<Activity(id={self.id}, tool='{self.tool_name}', action='{self.action}'{file_info})>"
|
||||
|
||||
@property
|
||||
def is_file_operation(self) -> bool:
|
||||
"""Check if this activity involves file operations."""
|
||||
return self.tool_name in {"Edit", "Write", "Read"}
|
||||
|
||||
@property
|
||||
def is_code_execution(self) -> bool:
|
||||
"""Check if this activity involves code/command execution."""
|
||||
return self.tool_name in {"Bash", "Task"}
|
||||
|
||||
@property
|
||||
def is_search_operation(self) -> bool:
|
||||
"""Check if this activity involves searching."""
|
||||
return self.tool_name in {"Grep", "Glob"}
|
||||
|
||||
@property
|
||||
def total_lines_changed(self) -> int:
|
||||
"""Get total lines changed (added + removed)."""
|
||||
added = self.lines_added or 0
|
||||
removed = self.lines_removed or 0
|
||||
return added + removed
|
||||
|
||||
@property
|
||||
def net_lines_changed(self) -> int:
|
||||
"""Get net lines changed (added - removed)."""
|
||||
added = self.lines_added or 0
|
||||
removed = self.lines_removed or 0
|
||||
return added - removed
|
||||
|
||||
def get_file_extension(self) -> Optional[str]:
|
||||
"""Extract file extension from file path."""
|
||||
if not self.file_path:
|
||||
return None
|
||||
|
||||
if "." in self.file_path:
|
||||
return self.file_path.split(".")[-1].lower()
|
||||
return None
|
||||
|
||||
def get_programming_language(self) -> Optional[str]:
|
||||
"""Infer programming language from file extension."""
|
||||
ext = self.get_file_extension()
|
||||
if not ext:
|
||||
return None
|
||||
|
||||
language_map = {
|
||||
"py": "python",
|
||||
"js": "javascript",
|
||||
"ts": "typescript",
|
||||
"jsx": "javascript",
|
||||
"tsx": "typescript",
|
||||
"go": "go",
|
||||
"rs": "rust",
|
||||
"java": "java",
|
||||
"cpp": "cpp",
|
||||
"c": "c",
|
||||
"h": "c",
|
||||
"hpp": "cpp",
|
||||
"rb": "ruby",
|
||||
"php": "php",
|
||||
"html": "html",
|
||||
"css": "css",
|
||||
"scss": "scss",
|
||||
"sql": "sql",
|
||||
"md": "markdown",
|
||||
"yml": "yaml",
|
||||
"yaml": "yaml",
|
||||
"json": "json",
|
||||
"xml": "xml",
|
||||
"sh": "shell",
|
||||
"bash": "shell",
|
||||
}
|
||||
|
||||
return language_map.get(ext)
|
||||
|
||||
def is_successful(self) -> bool:
|
||||
"""Check if the activity completed successfully."""
|
||||
return self.success and not self.error_message
|
||||
|
||||
def get_command_executed(self) -> Optional[str]:
|
||||
"""Get the command that was executed (for Bash activities)."""
|
||||
if self.tool_name == "Bash" and self.metadata:
|
||||
return self.metadata.get("command")
|
||||
return None
|
||||
|
||||
def get_search_pattern(self) -> Optional[str]:
|
||||
"""Get the search pattern (for Grep activities)."""
|
||||
if self.tool_name == "Grep" and self.metadata:
|
||||
return self.metadata.get("pattern")
|
||||
return None
|
||||
|
||||
def get_task_type(self) -> Optional[str]:
|
||||
"""Get the task type (for Task activities)."""
|
||||
if self.tool_name == "Task" and self.metadata:
|
||||
return self.metadata.get("task_type")
|
||||
return None
|
||||
32
app/models/base.py
Normal file
32
app/models/base.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""
|
||||
Base model configuration for SQLAlchemy models.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin to add created_at and updated_at timestamps to models."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False
|
||||
)
|
||||
118
app/models/conversation.py
Normal file
118
app/models/conversation.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
Conversation model for tracking dialogue between user and Claude.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Conversation(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a conversation exchange between user and Claude.
|
||||
|
||||
Each conversation entry captures either a user prompt or Claude's response,
|
||||
along with context about tools used and files affected.
|
||||
"""
|
||||
|
||||
__tablename__ = "conversations"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Foreign key to session
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
|
||||
|
||||
# Timing
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=func.now(),
|
||||
index=True
|
||||
)
|
||||
|
||||
# Conversation content
|
||||
user_prompt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
claude_response: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Context and metadata
|
||||
tools_used: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
|
||||
files_affected: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
|
||||
context: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Token estimates for analysis
|
||||
tokens_input: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
tokens_output: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Exchange type for categorization
|
||||
exchange_type: Mapped[str] = mapped_column(String(50), nullable=False) # user_prompt, claude_response
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship("Session", back_populates="conversations")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
content_preview = ""
|
||||
if self.user_prompt:
|
||||
content_preview = self.user_prompt[:50] + "..." if len(self.user_prompt) > 50 else self.user_prompt
|
||||
elif self.claude_response:
|
||||
content_preview = self.claude_response[:50] + "..." if len(self.claude_response) > 50 else self.claude_response
|
||||
|
||||
return f"<Conversation(id={self.id}, type='{self.exchange_type}', content='{content_preview}')>"
|
||||
|
||||
@property
|
||||
def is_user_prompt(self) -> bool:
|
||||
"""Check if this is a user prompt."""
|
||||
return self.exchange_type == "user_prompt"
|
||||
|
||||
@property
|
||||
def is_claude_response(self) -> bool:
|
||||
"""Check if this is a Claude response."""
|
||||
return self.exchange_type == "claude_response"
|
||||
|
||||
@property
|
||||
def content_length(self) -> int:
|
||||
"""Get the total character length of the conversation content."""
|
||||
user_length = len(self.user_prompt) if self.user_prompt else 0
|
||||
claude_length = len(self.claude_response) if self.claude_response else 0
|
||||
return user_length + claude_length
|
||||
|
||||
@property
|
||||
def estimated_tokens(self) -> int:
|
||||
"""Estimate total tokens in this conversation exchange."""
|
||||
if self.tokens_input and self.tokens_output:
|
||||
return self.tokens_input + self.tokens_output
|
||||
|
||||
# Rough estimation: ~4 characters per token
|
||||
return self.content_length // 4
|
||||
|
||||
def get_intent_category(self) -> Optional[str]:
|
||||
"""Extract intent category from context if available."""
|
||||
if self.context and "intent" in self.context:
|
||||
return self.context["intent"]
|
||||
return None
|
||||
|
||||
def get_complexity_level(self) -> Optional[str]:
|
||||
"""Extract complexity level from context if available."""
|
||||
if self.context and "complexity" in self.context:
|
||||
return self.context["complexity"]
|
||||
return None
|
||||
|
||||
def has_file_operations(self) -> bool:
|
||||
"""Check if this conversation involved file operations."""
|
||||
if not self.tools_used:
|
||||
return False
|
||||
|
||||
file_tools = {"Edit", "Write", "Read"}
|
||||
return any(tool in file_tools for tool in self.tools_used)
|
||||
|
||||
def has_code_execution(self) -> bool:
|
||||
"""Check if this conversation involved code execution."""
|
||||
if not self.tools_used:
|
||||
return False
|
||||
|
||||
execution_tools = {"Bash", "Task"}
|
||||
return any(tool in execution_tools for tool in self.tools_used)
|
||||
202
app/models/git_operation.py
Normal file
202
app/models/git_operation.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""
|
||||
Git operation model for tracking repository changes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class GitOperation(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a git operation performed during a development session.
|
||||
|
||||
Tracks commits, pushes, pulls, branch operations, and other git commands
|
||||
to provide insight into version control workflow.
|
||||
"""
|
||||
|
||||
__tablename__ = "git_operations"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Foreign key to session
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
|
||||
|
||||
# Operation timing
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=func.now(),
|
||||
index=True
|
||||
)
|
||||
|
||||
# Operation details
|
||||
operation: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # commit, push, pull, branch, etc.
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
result: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# File and change tracking
|
||||
files_changed: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
|
||||
lines_added: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
lines_removed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Commit-specific fields
|
||||
commit_hash: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
|
||||
# Branch operation fields
|
||||
branch_from: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
branch_to: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship("Session", back_populates="git_operations")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<GitOperation(id={self.id}, operation='{self.operation}', success={self.success})>"
|
||||
|
||||
@property
|
||||
def is_commit(self) -> bool:
|
||||
"""Check if this is a commit operation."""
|
||||
return self.operation == "commit"
|
||||
|
||||
@property
|
||||
def is_push(self) -> bool:
|
||||
"""Check if this is a push operation."""
|
||||
return self.operation == "push"
|
||||
|
||||
@property
|
||||
def is_pull(self) -> bool:
|
||||
"""Check if this is a pull operation."""
|
||||
return self.operation == "pull"
|
||||
|
||||
@property
|
||||
def is_branch_operation(self) -> bool:
|
||||
"""Check if this is a branch-related operation."""
|
||||
return self.operation in {"branch", "checkout", "merge", "rebase"}
|
||||
|
||||
@property
|
||||
def total_lines_changed(self) -> int:
|
||||
"""Get total lines changed (added + removed)."""
|
||||
added = self.lines_added or 0
|
||||
removed = self.lines_removed or 0
|
||||
return added + removed
|
||||
|
||||
@property
|
||||
def net_lines_changed(self) -> int:
|
||||
"""Get net lines changed (added - removed)."""
|
||||
added = self.lines_added or 0
|
||||
removed = self.lines_removed or 0
|
||||
return added - removed
|
||||
|
||||
@property
|
||||
def files_count(self) -> int:
|
||||
"""Get number of files changed."""
|
||||
return len(self.files_changed) if self.files_changed else 0
|
||||
|
||||
def get_commit_message(self) -> Optional[str]:
|
||||
"""Extract commit message from the command."""
|
||||
if not self.is_commit:
|
||||
return None
|
||||
|
||||
command = self.command
|
||||
if "-m" in command:
|
||||
# Extract message between quotes after -m
|
||||
parts = command.split("-m")
|
||||
if len(parts) > 1:
|
||||
message_part = parts[1].strip()
|
||||
# Remove quotes
|
||||
if message_part.startswith('"') and message_part.endswith('"'):
|
||||
return message_part[1:-1]
|
||||
elif message_part.startswith("'") and message_part.endswith("'"):
|
||||
return message_part[1:-1]
|
||||
else:
|
||||
# Find first quoted string
|
||||
import re
|
||||
match = re.search(r'["\']([^"\']*)["\']', message_part)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def get_branch_name(self) -> Optional[str]:
|
||||
"""Get branch name for branch operations."""
|
||||
if self.branch_to:
|
||||
return self.branch_to
|
||||
elif self.branch_from:
|
||||
return self.branch_from
|
||||
|
||||
# Try to extract from command
|
||||
if "checkout" in self.command:
|
||||
parts = self.command.split()
|
||||
if len(parts) > 2:
|
||||
return parts[-1] # Last argument is usually the branch
|
||||
|
||||
return None
|
||||
|
||||
def is_merge_commit(self) -> bool:
|
||||
"""Check if this is a merge commit."""
|
||||
commit_msg = self.get_commit_message()
|
||||
return commit_msg is not None and "merge" in commit_msg.lower()
|
||||
|
||||
def is_feature_commit(self) -> bool:
|
||||
"""Check if this appears to be a feature commit."""
|
||||
commit_msg = self.get_commit_message()
|
||||
if not commit_msg:
|
||||
return False
|
||||
|
||||
feature_keywords = ["add", "implement", "create", "new", "feature"]
|
||||
return any(keyword in commit_msg.lower() for keyword in feature_keywords)
|
||||
|
||||
def is_bugfix_commit(self) -> bool:
|
||||
"""Check if this appears to be a bugfix commit."""
|
||||
commit_msg = self.get_commit_message()
|
||||
if not commit_msg:
|
||||
return False
|
||||
|
||||
bugfix_keywords = ["fix", "bug", "resolve", "correct", "patch"]
|
||||
return any(keyword in commit_msg.lower() for keyword in bugfix_keywords)
|
||||
|
||||
def is_refactor_commit(self) -> bool:
|
||||
"""Check if this appears to be a refactoring commit."""
|
||||
commit_msg = self.get_commit_message()
|
||||
if not commit_msg:
|
||||
return False
|
||||
|
||||
refactor_keywords = ["refactor", "cleanup", "improve", "optimize", "reorganize"]
|
||||
return any(keyword in commit_msg.lower() for keyword in refactor_keywords)
|
||||
|
||||
def get_commit_category(self) -> str:
|
||||
"""Categorize the commit based on its message."""
|
||||
if not self.is_commit:
|
||||
return "non-commit"
|
||||
|
||||
if self.is_merge_commit():
|
||||
return "merge"
|
||||
elif self.is_feature_commit():
|
||||
return "feature"
|
||||
elif self.is_bugfix_commit():
|
||||
return "bugfix"
|
||||
elif self.is_refactor_commit():
|
||||
return "refactor"
|
||||
else:
|
||||
return "other"
|
||||
|
||||
def get_change_size_category(self) -> str:
|
||||
"""Categorize the size of changes in this operation."""
|
||||
total_changes = self.total_lines_changed
|
||||
|
||||
if total_changes == 0:
|
||||
return "no-changes"
|
||||
elif total_changes < 10:
|
||||
return "small"
|
||||
elif total_changes < 50:
|
||||
return "medium"
|
||||
elif total_changes < 200:
|
||||
return "large"
|
||||
else:
|
||||
return "very-large"
|
||||
69
app/models/project.py
Normal file
69
app/models/project.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Project model for tracking development projects.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, Text, Integer, DateTime, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Project(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a development project tracked by Claude Code.
|
||||
|
||||
A project is typically identified by its filesystem path and may
|
||||
correspond to a git repository.
|
||||
"""
|
||||
|
||||
__tablename__ = "projects"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Core project information
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True)
|
||||
git_repo: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
languages: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Activity tracking
|
||||
last_session: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
total_sessions: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
total_time_minutes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
files_modified_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
lines_changed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
# Relationships
|
||||
sessions: Mapped[List["Session"]] = relationship(
|
||||
"Session",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Session.start_time.desc()"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Project(id={self.id}, name='{self.name}', path='{self.path}')>"
|
||||
|
||||
@property
|
||||
def is_git_repo(self) -> bool:
|
||||
"""Check if this project is a git repository."""
|
||||
return self.git_repo is not None and self.git_repo != ""
|
||||
|
||||
@property
|
||||
def primary_language(self) -> Optional[str]:
|
||||
"""Get the primary programming language for this project."""
|
||||
if self.languages and len(self.languages) > 0:
|
||||
return self.languages[0]
|
||||
return None
|
||||
|
||||
def update_stats(self, session_duration_minutes: int, files_count: int, lines_count: int) -> None:
|
||||
"""Update project statistics after a session."""
|
||||
self.total_sessions += 1
|
||||
self.total_time_minutes += session_duration_minutes
|
||||
self.files_modified_count += files_count
|
||||
self.lines_changed_count += lines_count
|
||||
self.last_session = func.now()
|
||||
134
app/models/session.py
Normal file
134
app/models/session.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""
|
||||
Session model for tracking individual development sessions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy import String, Text, Integer, DateTime, JSON, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Session(Base, TimestampMixin):
|
||||
"""
|
||||
Represents an individual development session within a project.
|
||||
|
||||
A session starts when Claude Code is launched or resumed and ends
|
||||
when the user stops or the session is interrupted.
|
||||
"""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Foreign key to project
|
||||
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
|
||||
# Session timing
|
||||
start_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=func.now(),
|
||||
index=True
|
||||
)
|
||||
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Session metadata
|
||||
session_type: Mapped[str] = mapped_column(String(50), nullable=False) # startup, resume, clear
|
||||
working_directory: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
git_branch: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
environment: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Session statistics (updated as session progresses)
|
||||
duration_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
activity_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
conversation_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
files_touched: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="sessions")
|
||||
conversations: Mapped[List["Conversation"]] = relationship(
|
||||
"Conversation",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Conversation.timestamp"
|
||||
)
|
||||
activities: Mapped[List["Activity"]] = relationship(
|
||||
"Activity",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Activity.timestamp"
|
||||
)
|
||||
waiting_periods: Mapped[List["WaitingPeriod"]] = relationship(
|
||||
"WaitingPeriod",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WaitingPeriod.start_time"
|
||||
)
|
||||
git_operations: Mapped[List["GitOperation"]] = relationship(
|
||||
"GitOperation",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="GitOperation.timestamp"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Session(id={self.id}, project_id={self.project_id}, type='{self.session_type}')>"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if this session is still active (not ended)."""
|
||||
return self.end_time is None
|
||||
|
||||
@property
|
||||
def calculated_duration_minutes(self) -> Optional[int]:
|
||||
"""Calculate session duration in minutes."""
|
||||
if self.end_time is None:
|
||||
# Session is still active, calculate current duration
|
||||
current_duration = datetime.utcnow() - self.start_time
|
||||
return int(current_duration.total_seconds() / 60)
|
||||
else:
|
||||
# Session is finished
|
||||
if self.duration_minutes is not None:
|
||||
return self.duration_minutes
|
||||
else:
|
||||
duration = self.end_time - self.start_time
|
||||
return int(duration.total_seconds() / 60)
|
||||
|
||||
def end_session(self, end_reason: str = "normal") -> None:
|
||||
"""End the session and calculate final statistics."""
|
||||
if self.end_time is None:
|
||||
self.end_time = func.now()
|
||||
self.duration_minutes = self.calculated_duration_minutes
|
||||
|
||||
# Update project statistics
|
||||
if self.project:
|
||||
unique_files = len(set(self.files_touched or []))
|
||||
total_lines = sum(
|
||||
(activity.lines_added or 0) + (activity.lines_removed or 0)
|
||||
for activity in self.activities
|
||||
)
|
||||
self.project.update_stats(
|
||||
session_duration_minutes=self.duration_minutes or 0,
|
||||
files_count=unique_files,
|
||||
lines_count=total_lines
|
||||
)
|
||||
|
||||
def add_activity(self) -> None:
|
||||
"""Increment activity counter."""
|
||||
self.activity_count += 1
|
||||
|
||||
def add_conversation(self) -> None:
|
||||
"""Increment conversation counter."""
|
||||
self.conversation_count += 1
|
||||
|
||||
def add_file_touched(self, file_path: str) -> None:
|
||||
"""Add a file to the list of files touched in this session."""
|
||||
if self.files_touched is None:
|
||||
self.files_touched = []
|
||||
|
||||
if file_path not in self.files_touched:
|
||||
self.files_touched.append(file_path)
|
||||
156
app/models/waiting_period.py
Normal file
156
app/models/waiting_period.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
Waiting period model for tracking think time and engagement.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Text, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
|
||||
|
||||
class WaitingPeriod(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a period when Claude is waiting for user input.
|
||||
|
||||
These periods provide insight into user thinking time, engagement patterns,
|
||||
and workflow interruptions during development sessions.
|
||||
"""
|
||||
|
||||
__tablename__ = "waiting_periods"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Foreign key to session
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sessions.id"), nullable=False, index=True)
|
||||
|
||||
# Timing
|
||||
start_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=func.now(),
|
||||
index=True
|
||||
)
|
||||
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
duration_seconds: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
# Context
|
||||
context_before: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
context_after: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Activity inference
|
||||
likely_activity: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # thinking, research, external_work, break
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship("Session", back_populates="waiting_periods")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
duration_info = f", duration={self.duration_seconds}s" if self.duration_seconds else ""
|
||||
return f"<WaitingPeriod(id={self.id}, session_id={self.session_id}{duration_info})>"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if this waiting period is still active (not ended)."""
|
||||
return self.end_time is None
|
||||
|
||||
@property
|
||||
def calculated_duration_seconds(self) -> Optional[int]:
|
||||
"""Calculate waiting period duration in seconds."""
|
||||
if self.end_time is None:
|
||||
# Still waiting, calculate current duration
|
||||
current_duration = datetime.utcnow() - self.start_time
|
||||
return int(current_duration.total_seconds())
|
||||
else:
|
||||
# Finished waiting
|
||||
if self.duration_seconds is not None:
|
||||
return self.duration_seconds
|
||||
else:
|
||||
duration = self.end_time - self.start_time
|
||||
return int(duration.total_seconds())
|
||||
|
||||
@property
|
||||
def duration_minutes(self) -> Optional[float]:
|
||||
"""Get duration in minutes."""
|
||||
seconds = self.calculated_duration_seconds
|
||||
return seconds / 60 if seconds is not None else None
|
||||
|
||||
def end_waiting(self, context_after: Optional[str] = None) -> None:
|
||||
"""End the waiting period and calculate duration."""
|
||||
if self.end_time is None:
|
||||
self.end_time = func.now()
|
||||
self.duration_seconds = self.calculated_duration_seconds
|
||||
if context_after:
|
||||
self.context_after = context_after
|
||||
|
||||
def classify_activity(self) -> str:
|
||||
"""
|
||||
Classify the likely activity based on duration and context.
|
||||
|
||||
Returns one of: 'thinking', 'research', 'external_work', 'break'
|
||||
"""
|
||||
if self.likely_activity:
|
||||
return self.likely_activity
|
||||
|
||||
duration = self.calculated_duration_seconds
|
||||
if duration is None:
|
||||
return "unknown"
|
||||
|
||||
# Classification based on duration
|
||||
if duration < 10:
|
||||
return "thinking" # Quick pause
|
||||
elif duration < 60:
|
||||
return "thinking" # Short contemplation
|
||||
elif duration < 300: # 5 minutes
|
||||
return "research" # Looking something up
|
||||
elif duration < 1800: # 30 minutes
|
||||
return "external_work" # Working on something else
|
||||
else:
|
||||
return "break" # Extended break
|
||||
|
||||
@property
|
||||
def engagement_score(self) -> float:
|
||||
"""
|
||||
Calculate an engagement score based on waiting time.
|
||||
|
||||
Returns a score from 0.0 (disengaged) to 1.0 (highly engaged).
|
||||
"""
|
||||
duration = self.calculated_duration_seconds
|
||||
if duration is None:
|
||||
return 0.5 # Default neutral score
|
||||
|
||||
# Short waits indicate high engagement
|
||||
if duration <= 5:
|
||||
return 1.0
|
||||
elif duration <= 30:
|
||||
return 0.9
|
||||
elif duration <= 120: # 2 minutes
|
||||
return 0.7
|
||||
elif duration <= 300: # 5 minutes
|
||||
return 0.5
|
||||
elif duration <= 900: # 15 minutes
|
||||
return 0.3
|
||||
else:
|
||||
return 0.1 # Long waits indicate low engagement
|
||||
|
||||
def is_quick_response(self) -> bool:
|
||||
"""Check if user responded quickly (< 30 seconds)."""
|
||||
duration = self.calculated_duration_seconds
|
||||
return duration is not None and duration < 30
|
||||
|
||||
def is_thoughtful_pause(self) -> bool:
|
||||
"""Check if this was a thoughtful pause (30s - 2 minutes)."""
|
||||
duration = self.calculated_duration_seconds
|
||||
return duration is not None and 30 <= duration < 120
|
||||
|
||||
def is_research_break(self) -> bool:
|
||||
"""Check if this was likely a research break (2 - 15 minutes)."""
|
||||
duration = self.calculated_duration_seconds
|
||||
return duration is not None and 120 <= duration < 900
|
||||
|
||||
def is_extended_break(self) -> bool:
|
||||
"""Check if this was an extended break (> 15 minutes)."""
|
||||
duration = self.calculated_duration_seconds
|
||||
return duration is not None and duration >= 900
|
||||
Loading…
Add table
Add a link
Reference in a new issue