""" Daily Worktime Tracking and Cost Distribution System This module provides comprehensive worktime tracking functionality that estimates daily work time spent on issues and distributes costs proportionally based on time allocation rather than equal distribution. """ import sqlite3 import json from datetime import datetime, date, timedelta from typing import List, Dict, Any, Optional, Tuple from dataclasses import dataclass, asdict from decimal import Decimal from pathlib import Path from .models import FinanceModels @dataclass class WorktimeEntry: """Data class representing a worktime tracking entry.""" id: Optional[int] = None issue_id: int = None work_date: date = None start_time: Optional[datetime] = None end_time: Optional[datetime] = None duration_minutes: int = None description: Optional[str] = None entry_type: str = "manual" # manual, estimated, tracked created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @dataclass class DailySummary: """Data class for daily worktime summary.""" work_date: date total_minutes: int issue_count: int entries: List[WorktimeEntry] cost_per_minute: Optional[Decimal] = None total_cost_allocated: Optional[Decimal] = None class WorktimeTracker: """ Service for tracking worktime and distributing costs based on time allocation. Provides functionality to log work time, estimate daily effort, and calculate proportional cost distribution across issues based on time spent. """ def __init__(self, db_path: str = "markitect.db"): """ Initialize the worktime tracker. Args: db_path: Path to the SQLite database file """ self.db_path = db_path self.finance_models = FinanceModels(db_path) self._ensure_schema() def _ensure_schema(self): """Ensure the database schema is properly initialized.""" self.finance_models.initialize_finance_schema() self._create_worktime_tables() def _create_worktime_tables(self): """Create worktime-specific database tables.""" with self.finance_models.get_connection() as conn: cursor = conn.cursor() # Enable foreign key constraints cursor.execute('PRAGMA foreign_keys = ON') # Create worktime entries table cursor.execute(''' CREATE TABLE IF NOT EXISTS worktime_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id INTEGER NOT NULL, work_date DATE NOT NULL, start_time TIMESTAMP, end_time TIMESTAMP, duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0), description TEXT, entry_type TEXT CHECK (entry_type IN ('manual', 'estimated', 'tracked')) DEFAULT 'manual', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_issue_date_time UNIQUE (issue_id, work_date, start_time) ) ''') # Create daily worktime summaries table cursor.execute(''' CREATE TABLE IF NOT EXISTS daily_worktime_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_date DATE NOT NULL UNIQUE, total_minutes INTEGER NOT NULL DEFAULT 0, issue_count INTEGER NOT NULL DEFAULT 0, cost_per_minute DECIMAL(10,6), total_cost_allocated DECIMAL(10,2), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # Create cost distribution log table cursor.execute(''' CREATE TABLE IF NOT EXISTS worktime_cost_distributions ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_date DATE NOT NULL, issue_id INTEGER NOT NULL, time_minutes INTEGER NOT NULL, cost_allocated DECIMAL(10,2) NOT NULL, cost_per_minute DECIMAL(10,6) NOT NULL, period_id INTEGER REFERENCES cost_periods(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_date_issue_distribution UNIQUE (work_date, issue_id) ) ''') def log_worktime(self, issue_id: int, duration_minutes: int, work_date: Optional[date] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, description: Optional[str] = None, entry_type: str = "manual") -> int: """ Log worktime for an issue. Args: issue_id: ID of the issue duration_minutes: Duration of work in minutes work_date: Date when work was performed (defaults to today) start_time: Optional start timestamp end_time: Optional end timestamp description: Description of work performed entry_type: Type of entry (manual, estimated, tracked) Returns: ID of the created worktime entry Raises: ValueError: If duration is invalid sqlite3.Error: If database operation fails """ if duration_minutes <= 0: raise ValueError("Duration must be positive") if work_date is None: work_date = date.today() # Validate time consistency if start_time and end_time: calculated_duration = int((end_time - start_time).total_seconds() / 60) if abs(calculated_duration - duration_minutes) > 5: # Allow 5-minute tolerance print(f"⚠️ Warning: Calculated duration ({calculated_duration}min) differs from specified ({duration_minutes}min)") with self.finance_models.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' INSERT INTO worktime_entries (issue_id, work_date, start_time, end_time, duration_minutes, description, entry_type) VALUES (?, ?, ?, ?, ?, ?, ?) ''', (issue_id, work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date, start_time.isoformat() if hasattr(start_time, 'isoformat') else start_time, end_time.isoformat() if hasattr(end_time, 'isoformat') else end_time, duration_minutes, description, entry_type)) entry_id = cursor.lastrowid # Update daily summary self._update_daily_summary(work_date, conn) return entry_id def _update_daily_summary(self, work_date: date, conn: sqlite3.Connection): """Update the daily worktime summary for a given date.""" cursor = conn.cursor() # Calculate daily totals cursor.execute(''' SELECT COUNT(DISTINCT issue_id) as issue_count, SUM(duration_minutes) as total_minutes FROM worktime_entries WHERE work_date = ? ''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date,)) result = cursor.fetchone() issue_count = result[0] or 0 total_minutes = result[1] or 0 # Insert or update summary cursor.execute(''' INSERT OR REPLACE INTO daily_worktime_summaries (work_date, total_minutes, issue_count) VALUES (?, ?, ?) ''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date, total_minutes, issue_count)) def get_worktime_entries(self, issue_id: Optional[int] = None, work_date: Optional[date] = None, start_date: Optional[date] = None, end_date: Optional[date] = None, limit: Optional[int] = None) -> List[WorktimeEntry]: """ Get worktime entries with optional filtering. Args: issue_id: Filter by specific issue work_date: Filter by specific date start_date: Filter by date range start end_date: Filter by date range end limit: Maximum number of entries to return Returns: List of worktime entries """ with self.finance_models.get_connection() as conn: cursor = conn.cursor() query = ''' SELECT id, issue_id, work_date, start_time, end_time, duration_minutes, description, entry_type, created_at, updated_at FROM worktime_entries WHERE 1=1 ''' params = [] if issue_id: query += ' AND issue_id = ?' params.append(issue_id) if work_date: query += ' AND work_date = ?' params.append(work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date) elif start_date and end_date: query += ' AND work_date BETWEEN ? AND ?' params.extend([start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date, end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date]) elif start_date: query += ' AND work_date >= ?' params.append(start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date) elif end_date: query += ' AND work_date <= ?' params.append(end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date) query += ' ORDER BY work_date DESC, start_time DESC' if limit: query += ' LIMIT ?' params.append(limit) cursor.execute(query, params) rows = cursor.fetchall() entries = [] for row in rows: entry = WorktimeEntry( id=row[0], issue_id=row[1], work_date=datetime.strptime(row[2], '%Y-%m-%d').date() if row[2] else None, start_time=datetime.fromisoformat(row[3]) if row[3] else None, end_time=datetime.fromisoformat(row[4]) if row[4] else None, duration_minutes=row[5], description=row[6], entry_type=row[7], created_at=datetime.fromisoformat(row[8]) if row[8] else None, updated_at=datetime.fromisoformat(row[9]) if row[9] else None ) entries.append(entry) return entries def get_daily_summary(self, work_date: date) -> Optional[DailySummary]: """ Get daily worktime summary for a specific date. Args: work_date: Date to get summary for Returns: Daily summary or None if no data """ entries = self.get_worktime_entries(work_date=work_date) if not entries: return None total_minutes = sum(entry.duration_minutes for entry in entries) issue_count = len(set(entry.issue_id for entry in entries)) # Get cost allocation if available with self.finance_models.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT cost_per_minute, total_cost_allocated FROM daily_worktime_summaries WHERE work_date = ? ''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date,)) result = cursor.fetchone() cost_per_minute = Decimal(str(result[0])) if result and result[0] else None total_cost_allocated = Decimal(str(result[1])) if result and result[1] else None return DailySummary( work_date=work_date, total_minutes=total_minutes, issue_count=issue_count, entries=entries, cost_per_minute=cost_per_minute, total_cost_allocated=total_cost_allocated ) def estimate_daily_worktime(self, work_date: date, total_hours: float = 8.0, issues: Optional[List[int]] = None, distribution_method: str = "equal") -> Dict[str, Any]: """ Estimate worktime distribution for a day when specific times aren't tracked. Args: work_date: Date to estimate for total_hours: Total work hours for the day issues: List of issue IDs that were worked on distribution_method: How to distribute time (equal, activity_based, manual) Returns: Dictionary with estimation results """ total_minutes = int(total_hours * 60) if not issues: # Try to identify issues from activity log issues = self._get_active_issues_for_date(work_date) if not issues: raise ValueError("No issues specified and none found in activity log") estimates = {} if distribution_method == "equal": # Equal distribution across all issues minutes_per_issue = total_minutes // len(issues) remainder = total_minutes % len(issues) for i, issue_id in enumerate(issues): minutes = minutes_per_issue + (1 if i < remainder else 0) estimates[issue_id] = minutes elif distribution_method == "activity_based": # Distribute based on activity frequency activity_weights = self._get_activity_weights_for_date(work_date, issues) total_weight = sum(activity_weights.values()) if total_weight == 0: # Fallback to equal distribution minutes_per_issue = total_minutes // len(issues) for issue_id in issues: estimates[issue_id] = minutes_per_issue else: for issue_id in issues: weight = activity_weights.get(issue_id, 0) minutes = int((weight / total_weight) * total_minutes) estimates[issue_id] = minutes # Log estimated entries for issue_id, minutes in estimates.items(): if minutes > 0: self.log_worktime( issue_id=issue_id, duration_minutes=minutes, work_date=work_date, description=f"Estimated worktime ({distribution_method} distribution)", entry_type="estimated" ) return { "work_date": work_date, "total_minutes": total_minutes, "distribution_method": distribution_method, "issue_estimates": estimates, "issues_count": len(issues) } def _get_active_issues_for_date(self, work_date: date) -> List[int]: """Get issues that had activity on a specific date.""" with self.finance_models.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT DISTINCT issue_id FROM issue_activity_log WHERE activity_date = ? ''', (work_date,)) return [row[0] for row in cursor.fetchall()] def _get_activity_weights_for_date(self, work_date: date, issues: List[int]) -> Dict[int, int]: """Get activity weights for issues on a specific date.""" with self.finance_models.get_connection() as conn: cursor = conn.cursor() placeholders = ','.join(['?' for _ in issues]) cursor.execute(f''' SELECT issue_id, COUNT(*) as activity_count FROM issue_activity_log WHERE activity_date = ? AND issue_id IN ({placeholders}) GROUP BY issue_id ''', [work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date] + issues) return dict(cursor.fetchall()) def distribute_daily_costs(self, work_date: date, total_daily_cost: Decimal, period_id: Optional[int] = None) -> Dict[str, Any]: """ Distribute daily costs proportionally based on time spent on each issue. Args: work_date: Date to distribute costs for total_daily_cost: Total cost to distribute period_id: Cost period ID for tracking Returns: Dictionary with distribution results """ # Get worktime entries for the date entries = self.get_worktime_entries(work_date=work_date) if not entries: return { "work_date": work_date, "total_cost": float(total_daily_cost), "distributions": {}, "message": "No worktime entries found for this date" } # Calculate time per issue issue_minutes = {} for entry in entries: issue_minutes[entry.issue_id] = issue_minutes.get(entry.issue_id, 0) + entry.duration_minutes total_minutes = sum(issue_minutes.values()) if total_minutes == 0: return { "work_date": work_date, "total_cost": float(total_daily_cost), "distributions": {}, "message": "No time tracked for this date" } cost_per_minute = total_daily_cost / total_minutes # Calculate cost distribution distributions = {} with self.finance_models.get_connection() as conn: cursor = conn.cursor() for issue_id, minutes in issue_minutes.items(): cost_allocated = cost_per_minute * minutes distributions[issue_id] = { "minutes": minutes, "percentage": (minutes / total_minutes) * 100, "cost_allocated": float(cost_allocated), "cost_per_minute": float(cost_per_minute) } # Log the distribution cursor.execute(''' INSERT OR REPLACE INTO worktime_cost_distributions (work_date, issue_id, time_minutes, cost_allocated, cost_per_minute, period_id) VALUES (?, ?, ?, ?, ?, ?) ''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date, issue_id, minutes, float(cost_allocated), float(cost_per_minute), period_id)) # Update daily summary with cost information cursor.execute(''' UPDATE daily_worktime_summaries SET cost_per_minute = ?, total_cost_allocated = ?, updated_at = CURRENT_TIMESTAMP WHERE work_date = ? ''', (float(cost_per_minute), float(total_daily_cost), work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date)) return { "work_date": work_date, "total_cost": float(total_daily_cost), "total_minutes": total_minutes, "cost_per_minute": float(cost_per_minute), "distributions": distributions, "issues_count": len(distributions) } def get_worktime_report(self, start_date: Optional[date] = None, end_date: Optional[date] = None, issue_id: Optional[int] = None) -> Dict[str, Any]: """ Generate comprehensive worktime report. Args: start_date: Report start date end_date: Report end date issue_id: Filter by specific issue Returns: Comprehensive worktime report """ if not start_date: start_date = date.today() - timedelta(days=30) # Last 30 days if not end_date: end_date = date.today() entries = self.get_worktime_entries( issue_id=issue_id, start_date=start_date, end_date=end_date ) if not entries: return { "period": f"{start_date} to {end_date}", "total_entries": 0, "total_time": {"hours": 0, "minutes": 0}, "summary": "No worktime entries found" } # Calculate summary statistics total_minutes = sum(entry.duration_minutes for entry in entries) issue_breakdown = {} daily_breakdown = {} for entry in entries: # Issue breakdown if entry.issue_id not in issue_breakdown: issue_breakdown[entry.issue_id] = { "total_minutes": 0, "entry_count": 0, "dates": set() } issue_breakdown[entry.issue_id]["total_minutes"] += entry.duration_minutes issue_breakdown[entry.issue_id]["entry_count"] += 1 issue_breakdown[entry.issue_id]["dates"].add(entry.work_date) # Daily breakdown date_str = entry.work_date.isoformat() if date_str not in daily_breakdown: daily_breakdown[date_str] = { "total_minutes": 0, "issues": set(), "entries": 0 } daily_breakdown[date_str]["total_minutes"] += entry.duration_minutes daily_breakdown[date_str]["issues"].add(entry.issue_id) daily_breakdown[date_str]["entries"] += 1 # Convert sets to counts for JSON serialization for issue_id in issue_breakdown: issue_breakdown[issue_id]["unique_dates"] = len(issue_breakdown[issue_id]["dates"]) del issue_breakdown[issue_id]["dates"] for date_str in daily_breakdown: daily_breakdown[date_str]["unique_issues"] = len(daily_breakdown[date_str]["issues"]) del daily_breakdown[date_str]["issues"] return { "period": f"{start_date} to {end_date}", "total_entries": len(entries), "total_time": { "hours": total_minutes // 60, "minutes": total_minutes % 60, "total_minutes": total_minutes }, "unique_issues": len(issue_breakdown), "unique_dates": len(daily_breakdown), "average_minutes_per_day": total_minutes / len(daily_breakdown) if daily_breakdown else 0, "issue_breakdown": issue_breakdown, "daily_breakdown": daily_breakdown } def delete_worktime_entry(self, entry_id: int) -> bool: """ Delete a worktime entry and update summaries. Args: entry_id: ID of the entry to delete Returns: True if deleted successfully, False otherwise """ with self.finance_models.get_connection() as conn: cursor = conn.cursor() # Get the entry details before deletion for summary update cursor.execute(''' SELECT work_date FROM worktime_entries WHERE id = ? ''', (entry_id,)) result = cursor.fetchone() if not result: return False work_date = datetime.strptime(result[0], '%Y-%m-%d').date() # Delete the entry cursor.execute('DELETE FROM worktime_entries WHERE id = ?', (entry_id,)) if cursor.rowcount > 0: # Update daily summary self._update_daily_summary(work_date, conn) return True return False def update_worktime_entry(self, entry_id: int, duration_minutes: Optional[int] = None, description: Optional[str] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None) -> bool: """ Update an existing worktime entry. Args: entry_id: ID of the entry to update duration_minutes: New duration in minutes description: New description start_time: New start time end_time: New end time Returns: True if updated successfully, False otherwise """ updates = {} params = [] if duration_minutes is not None: updates["duration_minutes"] = "?" params.append(duration_minutes) if description is not None: updates["description"] = "?" params.append(description) if start_time is not None: updates["start_time"] = "?" params.append(start_time) if end_time is not None: updates["end_time"] = "?" params.append(end_time) if not updates: return False updates["updated_at"] = "CURRENT_TIMESTAMP" with self.finance_models.get_connection() as conn: cursor = conn.cursor() # Get work date for summary update cursor.execute('SELECT work_date FROM worktime_entries WHERE id = ?', (entry_id,)) result = cursor.fetchone() if not result: return False work_date = datetime.strptime(result[0], '%Y-%m-%d').date() # Build update query set_clause = ", ".join(f"{col} = {val}" for col, val in updates.items()) params.append(entry_id) cursor.execute(f''' UPDATE worktime_entries SET {set_clause} WHERE id = ? ''', params) if cursor.rowcount > 0: # Update daily summary self._update_daily_summary(work_date, conn) return True return False