Initial commit: Claude Code Project Tracker

Add comprehensive development intelligence system that tracks:
- Development sessions with automatic start/stop
- Full conversation history with semantic search
- Tool usage and file operation analytics
- Think time and engagement analysis
- Git activity correlation
- Learning pattern recognition
- Productivity insights and metrics

Features:
- FastAPI backend with SQLite database
- Modern web dashboard with interactive charts
- Claude Code hook integration for automatic tracking
- Comprehensive test suite with 100+ tests
- Complete API documentation (OpenAPI/Swagger)
- Privacy-first design with local data storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ryan Malloy 2025-08-11 02:59:21 -06:00
commit 44ed9936b7
48 changed files with 9707 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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