diff --git a/markitect/cli.py b/markitect/cli.py index a3d767e5..7a6f5274 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -6386,6 +6386,10 @@ cli.add_command(activity_group) from markitect.finance.worktime_commands import worktime as worktime_group cli.add_command(worktime_group) +# Register day wrap-up commands +from markitect.finance.day_wrapup_commands import wrapup as wrapup_group +cli.add_command(wrapup_group) + # Query Paradigm Commands - Issue #62 @click.group() diff --git a/markitect/finance/day_wrapup_commands.py b/markitect/finance/day_wrapup_commands.py new file mode 100644 index 00000000..2b76837a --- /dev/null +++ b/markitect/finance/day_wrapup_commands.py @@ -0,0 +1,507 @@ +""" +Single Command Day Wrap-Up functionality. + +This module provides a comprehensive end-of-day command that consolidates +daily work summaries, activity tracking, cost distribution, and reporting +into a single convenient command. +""" + +import click +from datetime import datetime, date, timedelta +from typing import Optional, Dict, Any, List +from decimal import Decimal +from tabulate import tabulate +import json + +from .worktime_tracker import WorktimeTracker +from ..issues.activity_tracker import IssueActivityTracker +from .session_tracker import SessionCostTracker + + +class DayWrapUpService: + """Service for comprehensive day wrap-up functionality.""" + + def __init__(self, db_path: str = "markitect.db"): + """Initialize the day wrap-up service.""" + self.db_path = db_path + self.worktime_tracker = WorktimeTracker(db_path) + self.activity_tracker = IssueActivityTracker(db_path) + self.session_tracker = SessionCostTracker(db_path) + + def generate_daily_summary(self, target_date: date) -> Dict[str, Any]: + """ + Generate comprehensive daily summary. + + Args: + target_date: Date to generate summary for + + Returns: + Dictionary containing complete daily summary + """ + summary = { + 'date': target_date, + 'worktime': self._get_worktime_summary(target_date), + 'activities': self._get_activity_summary(target_date), + 'costs': self._get_cost_summary(target_date), + 'recommendations': [] + } + + # Add recommendations based on data + summary['recommendations'] = self._generate_recommendations(summary) + + return summary + + def _get_worktime_summary(self, target_date: date) -> Dict[str, Any]: + """Get worktime summary for the date.""" + daily_summary = self.worktime_tracker.get_daily_summary(target_date) + + if not daily_summary: + return { + 'total_minutes': 0, + 'total_hours': 0.0, + 'issues_worked': 0, + 'entries': [], + 'cost_allocated': None, + 'cost_per_minute': None + } + + # Get issue breakdown + issue_breakdown = {} + for entry in daily_summary.entries: + if entry.issue_id not in issue_breakdown: + issue_breakdown[entry.issue_id] = { + 'minutes': 0, + 'entries': 0, + 'descriptions': [] + } + issue_breakdown[entry.issue_id]['minutes'] += entry.duration_minutes + issue_breakdown[entry.issue_id]['entries'] += 1 + if entry.description: + issue_breakdown[entry.issue_id]['descriptions'].append(entry.description) + + return { + 'total_minutes': daily_summary.total_minutes, + 'total_hours': daily_summary.total_minutes / 60, + 'issues_worked': daily_summary.issue_count, + 'entries': len(daily_summary.entries), + 'issue_breakdown': issue_breakdown, + 'cost_allocated': float(daily_summary.total_cost_allocated) if daily_summary.total_cost_allocated else None, + 'cost_per_minute': float(daily_summary.cost_per_minute) if daily_summary.cost_per_minute else None + } + + def _get_activity_summary(self, target_date: date) -> Dict[str, Any]: + """Get activity summary for the date.""" + summary = self.activity_tracker.get_activity_summary( + start_date=target_date, + end_date=target_date + ) + + # Get detailed activities for the day + activities = [] + if summary['total_activities'] > 0: + # Get activities by checking each issue that had activity + with self.activity_tracker.finance_models.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT issue_id, activity_type, activity_details, created_at + FROM issue_activity_log + WHERE activity_date = ? + ORDER BY created_at DESC + ''', (target_date,)) + + for row in cursor.fetchall(): + activities.append({ + 'issue_id': row[0], + 'activity_type': row[1], + 'details': row[2], + 'created_at': row[3] + }) + + return { + 'total_activities': summary['total_activities'], + 'unique_issues': summary['unique_issues'], + 'activities_by_type': summary['activities_by_type'], + 'activities': activities + } + + def _get_cost_summary(self, target_date: date) -> Dict[str, Any]: + """Get cost summary for the date.""" + # Get session costs from cost notes for the day + cost_summary = self.session_tracker.get_issue_costs_summary() + + # Filter for today's costs (this is approximate - would need better filtering in real implementation) + daily_costs = 0.0 + issue_costs = {} + + # Get worktime cost distribution if available + with self.worktime_tracker.finance_models.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT issue_id, cost_allocated + FROM worktime_cost_distributions + WHERE work_date = ? + ''', (target_date,)) + + for row in cursor.fetchall(): + issue_id, cost = row + issue_costs[issue_id] = cost + daily_costs += cost + + return { + 'daily_total': daily_costs, + 'issue_costs': issue_costs, + 'has_cost_allocation': len(issue_costs) > 0 + } + + def _generate_recommendations(self, summary: Dict[str, Any]) -> List[str]: + """Generate recommendations based on daily summary.""" + recommendations = [] + + # Worktime recommendations + worktime = summary['worktime'] + if worktime['total_minutes'] == 0: + recommendations.append("โš ๏ธ No worktime logged for today. Consider logging time spent on issues.") + elif worktime['total_hours'] < 4: + recommendations.append("โฐ Low worktime logged today. Is this accurate or should more time be added?") + elif worktime['total_hours'] > 10: + recommendations.append("๐Ÿ”ฅ High worktime logged today. Make sure to take breaks!") + + # Activity recommendations + activities = summary['activities'] + if activities['total_activities'] == 0: + recommendations.append("๐Ÿ“ No issue activities logged today. Consider what issues you worked on.") + elif activities['unique_issues'] > 5: + recommendations.append("๐Ÿคน Many issues worked on today. Consider focusing on fewer issues for better productivity.") + + # Cost recommendations + costs = summary['costs'] + if worktime['total_minutes'] > 0 and not costs['has_cost_allocation']: + recommendations.append("๐Ÿ’ฐ Time logged but no costs distributed. Run cost distribution to allocate daily expenses.") + + return recommendations + + def perform_auto_estimation(self, target_date: date, total_hours: float = 8.0) -> Dict[str, Any]: + """ + Perform automatic worktime estimation if no time is logged. + + Args: + target_date: Date to estimate for + total_hours: Total hours to distribute + + Returns: + Estimation results + """ + # Check if any time is already logged + summary = self.worktime_tracker.get_daily_summary(target_date) + if summary and summary.total_minutes > 0: + return { + 'estimated': False, + 'reason': 'Time already logged for this date', + 'existing_minutes': summary.total_minutes + } + + # Get active issues for the day from activity log + with self.activity_tracker.finance_models.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT DISTINCT issue_id + FROM issue_activity_log + WHERE activity_date = ? + ''', (target_date,)) + active_issues = [row[0] for row in cursor.fetchall()] + + if not active_issues: + return { + 'estimated': False, + 'reason': 'No active issues found for this date', + 'active_issues': [] + } + + # Perform estimation + estimation_result = self.worktime_tracker.estimate_daily_worktime( + work_date=target_date, + total_hours=total_hours, + issues=active_issues, + distribution_method="activity_based" + ) + + return { + 'estimated': True, + 'estimation_result': estimation_result + } + + def distribute_daily_costs(self, target_date: date, daily_cost: Decimal) -> Dict[str, Any]: + """ + Distribute daily costs based on worktime allocation. + + Args: + target_date: Date to distribute costs for + daily_cost: Total daily cost to distribute + + Returns: + Distribution results + """ + return self.worktime_tracker.distribute_daily_costs( + work_date=target_date, + total_daily_cost=daily_cost + ) + + +@click.group() +def wrapup(): + """Day wrap-up commands for end-of-day summaries and automation.""" + pass + + +@wrapup.command() +@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']), required=False) +@click.option('--auto-estimate', is_flag=True, + help='Automatically estimate worktime if none logged') +@click.option('--estimate-hours', type=float, default=8.0, + help='Hours to estimate (used with --auto-estimate)') +@click.option('--distribute-cost', type=float, + help='Daily cost to distribute (โ‚ฌ)') +@click.option('--format', 'output_format', type=click.Choice(['summary', 'detailed', 'json']), + default='summary', help='Output format') +def daily(date: Optional[datetime], auto_estimate: bool, estimate_hours: float, + distribute_cost: Optional[float], output_format: str): + """Generate comprehensive daily wrap-up summary. + + If no date is provided, uses today's date. + """ + from datetime import date as date_module + target_date = date.date() if date else date_module.today() + service = DayWrapUpService() + + try: + # Auto-estimate worktime if requested + if auto_estimate: + click.echo(f"๐Ÿค– Auto-estimating worktime for {target_date}...") + estimation = service.perform_auto_estimation(target_date, estimate_hours) + + if estimation['estimated']: + result = estimation['estimation_result'] + click.echo(f"โœ… Estimated {estimate_hours}h across {result['issues_count']} issues") + else: + click.echo(f"โ„น๏ธ {estimation['reason']}") + + # Distribute costs if requested + if distribute_cost: + click.echo(f"๐Ÿ’ฐ Distributing โ‚ฌ{distribute_cost:.2f} for {target_date}...") + distribution = service.distribute_daily_costs(target_date, Decimal(str(distribute_cost))) + + if 'message' in distribution: + click.echo(f"โš ๏ธ {distribution['message']}") + else: + click.echo(f"โœ… Distributed โ‚ฌ{distribute_cost:.2f} across {distribution['issues_count']} issues") + + # Generate summary + summary = service.generate_daily_summary(target_date) + + if output_format == 'json': + # Convert date to string for JSON serialization + summary['date'] = summary['date'].isoformat() + click.echo(json.dumps(summary, indent=2)) + return + + # Display summary + _display_daily_summary(summary, output_format) + + except Exception as e: + click.echo(f"โŒ Error generating daily wrap-up: {e}", err=True) + raise click.Abort() + + +@wrapup.command() +@click.argument('start_date', type=click.DateTime(formats=['%Y-%m-%d'])) +@click.argument('end_date', type=click.DateTime(formats=['%Y-%m-%d'])) +@click.option('--format', 'output_format', type=click.Choice(['summary', 'json']), + default='summary', help='Output format') +def period(start_date: datetime, end_date: datetime, output_format: str): + """Generate wrap-up summary for a date range.""" + service = DayWrapUpService() + + try: + # Get worktime report for period + worktime_report = service.worktime_tracker.get_worktime_report( + start_date=start_date.date(), + end_date=end_date.date() + ) + + # Get activity summary for period + activity_summary = service.activity_tracker.get_activity_summary( + start_date=start_date.date(), + end_date=end_date.date() + ) + + period_summary = { + 'period': f"{start_date.date()} to {end_date.date()}", + 'worktime': worktime_report, + 'activities': activity_summary + } + + if output_format == 'json': + click.echo(json.dumps(period_summary, indent=2)) + else: + _display_period_summary(period_summary) + + except Exception as e: + click.echo(f"โŒ Error generating period wrap-up: {e}", err=True) + raise click.Abort() + + +@wrapup.command() +@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']), required=False) +@click.option('--hours', type=float, default=8.0, help='Total hours worked') +@click.option('--method', type=click.Choice(['equal', 'activity_based']), + default='activity_based', help='Estimation method') +def estimate(date: Optional[datetime], hours: float, method: str): + """Estimate and log worktime for a day based on issue activities.""" + from datetime import date as date_module + target_date = date.date() if date else date_module.today() + service = DayWrapUpService() + + try: + estimation = service.perform_auto_estimation(target_date, hours) + + if not estimation['estimated']: + click.echo(f"โš ๏ธ {estimation['reason']}") + return + + result = estimation['estimation_result'] + click.echo(f"โœ… Estimated worktime for {target_date}") + click.echo(f"Total Hours: {hours}h") + click.echo(f"Distribution Method: {method}") + click.echo(f"Issues: {result['issues_count']}") + + # Show breakdown + headers = ['Issue', 'Time', 'Percentage'] + rows = [] + total_minutes = result['total_minutes'] + + for issue_id, minutes in result['issue_estimates'].items(): + percentage = (minutes / total_minutes) * 100 + hours_mins = f"{minutes//60}h{minutes%60}m" if minutes >= 60 else f"{minutes}m" + rows.append([f"#{issue_id}", hours_mins, f"{percentage:.1f}%"]) + + click.echo("\nEstimated Time Distribution:") + click.echo(tabulate(rows, headers=headers, tablefmt='grid')) + + except Exception as e: + click.echo(f"โŒ Error estimating worktime: {e}", err=True) + raise click.Abort() + + +def _display_daily_summary(summary: Dict[str, Any], format_type: str): + """Display daily summary in formatted output.""" + date_str = summary['date'] + worktime = summary['worktime'] + activities = summary['activities'] + costs = summary['costs'] + recommendations = summary['recommendations'] + + click.echo(f"\n๐Ÿ“Š Daily Wrap-Up for {date_str}") + click.echo("=" * 50) + + # Worktime section + click.echo(f"\nโฐ WORKTIME SUMMARY") + if worktime['total_minutes'] > 0: + hours = int(worktime['total_hours']) + minutes = int((worktime['total_hours'] - hours) * 60) + click.echo(f"Total Time: {hours}h {minutes}m ({worktime['total_minutes']} minutes)") + click.echo(f"Issues Worked: {worktime['issues_worked']}") + click.echo(f"Time Entries: {worktime['entries']}") + + if worktime['cost_allocated']: + click.echo(f"Cost Allocated: โ‚ฌ{worktime['cost_allocated']:.2f}") + click.echo(f"Cost per Minute: โ‚ฌ{worktime['cost_per_minute']:.4f}") + + if format_type == 'detailed' and worktime['issue_breakdown']: + click.echo("\nTime by Issue:") + headers = ['Issue', 'Time', 'Entries', 'Percentage'] + rows = [] + + for issue_id, data in worktime['issue_breakdown'].items(): + percentage = (data['minutes'] / worktime['total_minutes']) * 100 + time_str = f"{data['minutes']//60}h{data['minutes']%60}m" if data['minutes'] >= 60 else f"{data['minutes']}m" + rows.append([f"#{issue_id}", time_str, data['entries'], f"{percentage:.1f}%"]) + + click.echo(tabulate(rows, headers=headers, tablefmt='grid')) + else: + click.echo("No worktime logged today") + + # Activities section + click.echo(f"\n๐Ÿ“ ACTIVITIES SUMMARY") + if activities['total_activities'] > 0: + click.echo(f"Total Activities: {activities['total_activities']}") + click.echo(f"Issues with Activity: {activities['unique_issues']}") + + if activities['activities_by_type']: + click.echo("\nActivity Breakdown:") + for activity_type, count in activities['activities_by_type'].items(): + click.echo(f" {activity_type.title()}: {count}") + + if format_type == 'detailed' and activities['activities']: + click.echo("\nRecent Activities:") + for activity in activities['activities'][:5]: # Show last 5 + details = f" - {activity['details']}" if activity['details'] else "" + click.echo(f" #{activity['issue_id']}: {activity['activity_type']}{details}") + else: + click.echo("No activities logged today") + + # Costs section + click.echo(f"\n๐Ÿ’ฐ COST SUMMARY") + if costs['has_cost_allocation']: + click.echo(f"Daily Total: โ‚ฌ{costs['daily_total']:.2f}") + click.echo("Cost Allocation:") + for issue_id, cost in costs['issue_costs'].items(): + click.echo(f" Issue #{issue_id}: โ‚ฌ{cost:.2f}") + else: + click.echo("No cost allocation for today") + + # Recommendations section + if recommendations: + click.echo(f"\n๐Ÿ’ก RECOMMENDATIONS") + for rec in recommendations: + click.echo(f" {rec}") + + click.echo() + + +def _display_period_summary(summary: Dict[str, Any]): + """Display period summary in formatted output.""" + click.echo(f"\n๐Ÿ“ˆ Period Wrap-Up: {summary['period']}") + click.echo("=" * 60) + + worktime = summary['worktime'] + activities = summary['activities'] + + # Worktime summary + click.echo(f"\nโฐ WORKTIME OVERVIEW") + click.echo(f"Total Time: {worktime['total_time']['hours']}h {worktime['total_time']['minutes']}m") + click.echo(f"Total Entries: {worktime['total_entries']}") + click.echo(f"Unique Issues: {worktime['unique_issues']}") + click.echo(f"Unique Dates: {worktime['unique_dates']}") + + if worktime['unique_dates'] > 0: + avg_minutes = worktime['average_minutes_per_day'] + avg_hours = int(avg_minutes // 60) + avg_mins = int(avg_minutes % 60) + click.echo(f"Average per Day: {avg_hours}h {avg_mins}m") + + # Activities summary + click.echo(f"\n๐Ÿ“ ACTIVITIES OVERVIEW") + click.echo(f"Total Activities: {activities['total_activities']}") + click.echo(f"Unique Issues: {activities['unique_issues']}") + + if activities['activities_by_type']: + click.echo("\nActivity Types:") + for activity_type, count in activities['activities_by_type'].items(): + percentage = (count / activities['total_activities']) * 100 + click.echo(f" {activity_type.title()}: {count} ({percentage:.1f}%)") + + click.echo() + + +if __name__ == '__main__': + wrapup() \ No newline at end of file diff --git a/tests/test_issue_124_day_wrapup.py b/tests/test_issue_124_day_wrapup.py new file mode 100644 index 00000000..ef3c8e74 --- /dev/null +++ b/tests/test_issue_124_day_wrapup.py @@ -0,0 +1,621 @@ +""" +Tests for Issue #124 - Single command Day-Wrap-Up + +This module contains comprehensive tests for the day wrap-up functionality +that consolidates daily work summaries, activity tracking, cost distribution, +and reporting into a single convenient command. +""" + +import pytest +import tempfile +from datetime import datetime, date, timedelta +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path +from decimal import Decimal +import json + +from markitect.finance.day_wrapup_commands import DayWrapUpService, wrapup, _display_daily_summary, _display_period_summary + + +class TestDayWrapUpService: + """Test suite for DayWrapUpService.""" + + def setup_method(self): + """Set up test fixtures with temporary database.""" + self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False) + self.temp_db.close() + self.db_path = self.temp_db.name + self.service = DayWrapUpService(self.db_path) + + def teardown_method(self): + """Clean up test fixtures.""" + Path(self.db_path).unlink(missing_ok=True) + + def test_service_initialization(self): + """Test that service initializes properly with all trackers.""" + assert self.service.db_path == self.db_path + assert self.service.worktime_tracker is not None + assert self.service.activity_tracker is not None + assert self.service.session_tracker is not None + + def test_get_worktime_summary_no_data(self): + """Test worktime summary when no data exists.""" + today = date.today() + summary = self.service._get_worktime_summary(today) + + assert summary['total_minutes'] == 0 + assert summary['total_hours'] == 0.0 + assert summary['issues_worked'] == 0 + assert summary['entries'] == [] + assert summary['cost_allocated'] is None + assert summary['cost_per_minute'] is None + + def test_get_worktime_summary_with_data(self): + """Test worktime summary with logged data.""" + today = date.today() + + # Log some worktime + self.service.worktime_tracker.log_worktime(124, 90, work_date=today, description="Main work") + self.service.worktime_tracker.log_worktime(125, 60, work_date=today, description="Side work") + + summary = self.service._get_worktime_summary(today) + + assert summary['total_minutes'] == 150 # 90 + 60 + assert summary['total_hours'] == 2.5 + assert summary['issues_worked'] == 2 + assert summary['entries'] == 2 + assert len(summary['issue_breakdown']) == 2 + assert 124 in summary['issue_breakdown'] + assert 125 in summary['issue_breakdown'] + assert summary['issue_breakdown'][124]['minutes'] == 90 + assert summary['issue_breakdown'][125]['minutes'] == 60 + + def test_get_activity_summary_no_data(self): + """Test activity summary when no data exists.""" + today = date.today() + summary = self.service._get_activity_summary(today) + + assert summary['total_activities'] == 0 + assert summary['unique_issues'] == 0 + assert summary['activities_by_type'] == {} + assert summary['activities'] == [] + + def test_get_activity_summary_with_data(self): + """Test activity summary with logged data.""" + today = date.today() + + # Log some activities + from markitect.issues.activity_tracker import ActivityType + self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today, activity_details="Created issue") + self.service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today, activity_details="Updated issue") + self.service.activity_tracker.log_activity(125, ActivityType.CREATED, activity_date=today, activity_details="Created another") + + summary = self.service._get_activity_summary(today) + + assert summary['total_activities'] == 3 + assert summary['unique_issues'] == 2 + assert 'created' in summary['activities_by_type'] + assert 'modified' in summary['activities_by_type'] + assert summary['activities_by_type']['created'] == 2 + assert summary['activities_by_type']['modified'] == 1 + assert len(summary['activities']) == 3 + + def test_get_cost_summary_no_distribution(self): + """Test cost summary when no cost distribution exists.""" + today = date.today() + summary = self.service._get_cost_summary(today) + + assert summary['daily_total'] == 0.0 + assert summary['issue_costs'] == {} + assert summary['has_cost_allocation'] is False + + def test_get_cost_summary_with_distribution(self): + """Test cost summary with cost distribution data.""" + today = date.today() + + # Log worktime and distribute costs + self.service.worktime_tracker.log_worktime(124, 120, work_date=today) # 2 hours + self.service.worktime_tracker.log_worktime(125, 60, work_date=today) # 1 hour + + distribution = self.service.worktime_tracker.distribute_daily_costs( + work_date=today, + total_daily_cost=Decimal('90.00') # โ‚ฌ90 total + ) + + summary = self.service._get_cost_summary(today) + + assert summary['daily_total'] == 90.0 + assert summary['has_cost_allocation'] is True + assert len(summary['issue_costs']) == 2 + assert summary['issue_costs'][124] == 60.0 # 2/3 of โ‚ฌ90 + assert summary['issue_costs'][125] == 30.0 # 1/3 of โ‚ฌ90 + + def test_generate_recommendations_no_data(self): + """Test recommendation generation with no data.""" + summary = { + 'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0}, + 'activities': {'total_activities': 0, 'unique_issues': 0}, + 'costs': {'has_cost_allocation': False} + } + + recommendations = self.service._generate_recommendations(summary) + + assert len(recommendations) >= 2 + assert any("No worktime logged" in rec for rec in recommendations) + assert any("No issue activities logged" in rec for rec in recommendations) + + def test_generate_recommendations_with_data(self): + """Test recommendation generation with various data conditions.""" + # Test low worktime + summary = { + 'worktime': {'total_minutes': 120, 'total_hours': 2.0, 'issues_worked': 1}, + 'activities': {'total_activities': 5, 'unique_issues': 1}, + 'costs': {'has_cost_allocation': False} + } + + recommendations = self.service._generate_recommendations(summary) + assert any("Low worktime logged" in rec for rec in recommendations) + assert any("no costs distributed" in rec for rec in recommendations) + + # Test high worktime + summary['worktime'] = {'total_minutes': 660, 'total_hours': 11.0, 'issues_worked': 1} + recommendations = self.service._generate_recommendations(summary) + assert any("High worktime logged" in rec for rec in recommendations) + + # Test many issues + summary['activities'] = {'total_activities': 10, 'unique_issues': 6} + recommendations = self.service._generate_recommendations(summary) + assert any("Many issues worked on" in rec for rec in recommendations) + + def test_perform_auto_estimation_no_activities(self): + """Test auto estimation when no activities exist.""" + today = date.today() + + result = self.service.perform_auto_estimation(today, 8.0) + + assert result['estimated'] is False + assert "No active issues found" in result['reason'] + assert result['active_issues'] == [] + + def test_perform_auto_estimation_with_existing_time(self): + """Test auto estimation when time is already logged.""" + today = date.today() + + # Log some worktime first + self.service.worktime_tracker.log_worktime(124, 60, work_date=today) + + result = self.service.perform_auto_estimation(today, 8.0) + + assert result['estimated'] is False + assert "Time already logged" in result['reason'] + assert result['existing_minutes'] == 60 + + def test_perform_auto_estimation_success(self): + """Test successful auto estimation.""" + today = date.today() + + # Create activities for issues + from markitect.issues.activity_tracker import ActivityType + self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today) + self.service.activity_tracker.log_activity(125, ActivityType.MODIFIED, activity_date=today) + + result = self.service.perform_auto_estimation(today, 6.0) + + assert result['estimated'] is True + assert 'estimation_result' in result + estimation = result['estimation_result'] + assert estimation['total_minutes'] == 360 # 6 hours + assert estimation['issues_count'] == 2 + assert len(estimation['issue_estimates']) == 2 + + # Verify worktime entries were created + entries = self.service.worktime_tracker.get_worktime_entries(work_date=today) + assert len(entries) == 2 + assert all(e.entry_type == "estimated" for e in entries) + + def test_distribute_daily_costs(self): + """Test daily cost distribution functionality.""" + today = date.today() + + # Log worktime first + self.service.worktime_tracker.log_worktime(124, 180, work_date=today) # 3 hours + self.service.worktime_tracker.log_worktime(125, 120, work_date=today) # 2 hours + # Total: 5 hours (300 minutes) + + result = self.service.distribute_daily_costs(today, Decimal('150.00')) + + assert result['total_cost'] == 150.0 + assert result['total_minutes'] == 300 + assert result['cost_per_minute'] == 0.5 + assert result['distributions'][124]['cost_allocated'] == 90.0 # 3/5 * โ‚ฌ150 + assert result['distributions'][125]['cost_allocated'] == 60.0 # 2/5 * โ‚ฌ150 + + def test_generate_daily_summary_integration(self): + """Test complete daily summary generation.""" + today = date.today() + + # Create comprehensive test data + from markitect.issues.activity_tracker import ActivityType + + # Log worktime + self.service.worktime_tracker.log_worktime(124, 120, work_date=today, description="Main feature") + self.service.worktime_tracker.log_worktime(125, 60, work_date=today, description="Bug fix") + + # Log activities + self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today) + self.service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today) + self.service.activity_tracker.log_activity(125, ActivityType.CLOSED, activity_date=today) + + # Distribute costs + self.service.distribute_daily_costs(today, Decimal('90.00')) + + # Generate summary + summary = self.service.generate_daily_summary(today) + + # Verify summary structure + assert summary['date'] == today + assert 'worktime' in summary + assert 'activities' in summary + assert 'costs' in summary + assert 'recommendations' in summary + + # Verify worktime data + worktime = summary['worktime'] + assert worktime['total_minutes'] == 180 + assert worktime['total_hours'] == 3.0 + assert worktime['issues_worked'] == 2 + assert worktime['cost_allocated'] == 90.0 + + # Verify activity data + activities = summary['activities'] + assert activities['total_activities'] == 3 + assert activities['unique_issues'] == 2 + + # Verify cost data + costs = summary['costs'] + assert costs['daily_total'] == 90.0 + assert costs['has_cost_allocation'] is True + + # Verify recommendations exist + assert isinstance(summary['recommendations'], list) + + +class TestDayWrapUpCommands: + """Test suite for day wrap-up CLI commands.""" + + @patch('markitect.finance.day_wrapup_commands.DayWrapUpService') + def test_daily_command_basic(self, mock_service_class): + """Test the daily wrap-up command with basic functionality.""" + mock_service = Mock() + mock_service_class.return_value = mock_service + + # Mock the summary data + mock_summary = { + 'date': date.today(), + 'worktime': { + 'total_minutes': 180, + 'total_hours': 3.0, + 'issues_worked': 2, + 'entries': 2, + 'issue_breakdown': {124: {'minutes': 120, 'entries': 1}, 125: {'minutes': 60, 'entries': 1}}, + 'cost_allocated': 90.0, + 'cost_per_minute': 0.5 + }, + 'activities': { + 'total_activities': 3, + 'unique_issues': 2, + 'activities_by_type': {'created': 2, 'modified': 1}, + 'activities': [] + }, + 'costs': { + 'daily_total': 90.0, + 'issue_costs': {124: 60.0, 125: 30.0}, + 'has_cost_allocation': True + }, + 'recommendations': ["๐Ÿ’ฐ Costs distributed successfully"] + } + mock_service.generate_daily_summary.return_value = mock_summary + + from click.testing import CliRunner + runner = CliRunner() + + result = runner.invoke(wrapup, ['daily']) + + assert result.exit_code == 0 + assert "๐Ÿ“Š Daily Wrap-Up" in result.output + assert "โฐ WORKTIME SUMMARY" in result.output + assert "๐Ÿ“ ACTIVITIES SUMMARY" in result.output + assert "๐Ÿ’ฐ COST SUMMARY" in result.output + assert "๐Ÿ’ก RECOMMENDATIONS" in result.output + + @patch('markitect.finance.day_wrapup_commands.DayWrapUpService') + def test_daily_command_with_auto_estimate(self, mock_service_class): + """Test daily command with auto-estimation enabled.""" + mock_service = Mock() + mock_service_class.return_value = mock_service + + # Mock estimation result + mock_estimation = { + 'estimated': True, + 'estimation_result': { + 'total_minutes': 480, + 'issues_count': 2, + 'issue_estimates': {124: 240, 125: 240} + } + } + mock_service.perform_auto_estimation.return_value = mock_estimation + + # Mock summary + mock_service.generate_daily_summary.return_value = { + 'date': date.today(), + 'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0, 'entries': []}, + 'activities': {'total_activities': 0, 'unique_issues': 0, 'activities_by_type': {}, 'activities': []}, + 'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False}, + 'recommendations': [] + } + + from click.testing import CliRunner + runner = CliRunner() + + result = runner.invoke(wrapup, ['daily', '--auto-estimate', '--estimate-hours', '8']) + + assert result.exit_code == 0 + assert "๐Ÿค– Auto-estimating worktime" in result.output + assert "โœ… Estimated 8.0h across 2 issues" in result.output + mock_service.perform_auto_estimation.assert_called_once_with(date.today(), 8.0) + + @patch('markitect.finance.day_wrapup_commands.DayWrapUpService') + def test_daily_command_with_cost_distribution(self, mock_service_class): + """Test daily command with cost distribution.""" + mock_service = Mock() + mock_service_class.return_value = mock_service + + # Mock distribution result + mock_distribution = { + 'total_cost': 120.0, + 'total_minutes': 240, + 'issues_count': 2, + 'distributions': {124: {'cost_allocated': 80.0}, 125: {'cost_allocated': 40.0}} + } + mock_service.distribute_daily_costs.return_value = mock_distribution + + # Mock summary + mock_service.generate_daily_summary.return_value = { + 'date': date.today(), + 'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0, 'entries': []}, + 'activities': {'total_activities': 0, 'unique_issues': 0, 'activities_by_type': {}, 'activities': []}, + 'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False}, + 'recommendations': [] + } + + from click.testing import CliRunner + runner = CliRunner() + + result = runner.invoke(wrapup, ['daily', '--distribute-cost', '120']) + + assert result.exit_code == 0 + assert "๐Ÿ’ฐ Distributing โ‚ฌ120.00" in result.output + assert "โœ… Distributed โ‚ฌ120.00 across 2 issues" in result.output + mock_service.distribute_daily_costs.assert_called_once() + + @patch('markitect.finance.day_wrapup_commands.DayWrapUpService') + def test_daily_command_json_format(self, mock_service_class): + """Test daily command with JSON output format.""" + mock_service = Mock() + mock_service_class.return_value = mock_service + + mock_summary = { + 'date': date.today(), + 'worktime': {'total_minutes': 120, 'total_hours': 2.0, 'issues_worked': 1, 'entries': 1}, + 'activities': {'total_activities': 2, 'unique_issues': 1, 'activities_by_type': {}, 'activities': []}, + 'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False}, + 'recommendations': [] + } + mock_service.generate_daily_summary.return_value = mock_summary + + from click.testing import CliRunner + runner = CliRunner() + + result = runner.invoke(wrapup, ['daily', '--format', 'json']) + + assert result.exit_code == 0 + # Should be valid JSON + output_data = json.loads(result.output.strip()) + assert 'date' in output_data + assert 'worktime' in output_data + assert 'activities' in output_data + assert 'costs' in output_data + + @patch('markitect.finance.day_wrapup_commands.DayWrapUpService') + def test_estimate_command(self, mock_service_class): + """Test the estimate command.""" + mock_service = Mock() + mock_service_class.return_value = mock_service + + mock_estimation = { + 'estimated': True, + 'estimation_result': { + 'work_date': date.today(), + 'total_minutes': 480, # 8 hours + 'distribution_method': 'activity_based', + 'issue_estimates': {124: 300, 125: 180}, # 5h and 3h + 'issues_count': 2 + } + } + mock_service.perform_auto_estimation.return_value = mock_estimation + + from click.testing import CliRunner + runner = CliRunner() + + today = date.today().strftime('%Y-%m-%d') + result = runner.invoke(wrapup, ['estimate', today, '--hours', '8']) + + assert result.exit_code == 0 + assert "โœ… Estimated worktime" in result.output + assert "Total Hours: 8.0h" in result.output + assert "Issues: 2" in result.output + assert "Estimated Time Distribution:" in result.output + + @patch('markitect.finance.day_wrapup_commands.DayWrapUpService') + def test_period_command(self, mock_service_class): + """Test the period wrap-up command.""" + mock_service = Mock() + mock_service_class.return_value = mock_service + + # Mock worktime report + mock_worktime_report = { + 'period': '2025-10-01 to 2025-10-04', + 'total_entries': 8, + 'total_time': {'hours': 20, 'minutes': 30, 'total_minutes': 1230}, + 'unique_issues': 3, + 'unique_dates': 4, + 'average_minutes_per_day': 307.5 + } + mock_service.worktime_tracker.get_worktime_report.return_value = mock_worktime_report + + # Mock activity summary + mock_activity_summary = { + 'total_activities': 15, + 'unique_issues': 4, + 'activities_by_type': {'created': 8, 'modified': 5, 'closed': 2} + } + mock_service.activity_tracker.get_activity_summary.return_value = mock_activity_summary + + from click.testing import CliRunner + runner = CliRunner() + + result = runner.invoke(wrapup, ['period', '2025-10-01', '2025-10-04']) + + assert result.exit_code == 0 + assert "๐Ÿ“ˆ Period Wrap-Up" in result.output + assert "โฐ WORKTIME OVERVIEW" in result.output + assert "๐Ÿ“ ACTIVITIES OVERVIEW" in result.output + assert "Total Time: 20h 30m" in result.output + assert "Total Activities: 15" in result.output + + +class TestDayWrapUpIntegration: + """Integration tests for the complete day wrap-up system.""" + + def setup_method(self): + """Set up integration test fixtures.""" + self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False) + self.temp_db.close() + self.db_path = self.temp_db.name + + def teardown_method(self): + """Clean up integration test fixtures.""" + Path(self.db_path).unlink(missing_ok=True) + + def test_complete_day_workflow(self): + """Test a complete daily workflow from start to finish.""" + service = DayWrapUpService(self.db_path) + today = date.today() + + # 1. Start with empty day + initial_summary = service.generate_daily_summary(today) + assert initial_summary['worktime']['total_minutes'] == 0 + assert initial_summary['activities']['total_activities'] == 0 + + # 2. Log some activities + from markitect.issues.activity_tracker import ActivityType + service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today, activity_details="Started new feature") + service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today, activity_details="Made progress") + service.activity_tracker.log_activity(125, ActivityType.CLOSED, activity_date=today, activity_details="Fixed bug") + + # 3. Perform auto-estimation + estimation = service.perform_auto_estimation(today, 7.5) + assert estimation['estimated'] is True + assert estimation['estimation_result']['total_minutes'] == 450 # 7.5 hours + + # 4. Distribute costs + distribution = service.distribute_daily_costs(today, Decimal('112.50')) # โ‚ฌ15 per hour + assert distribution['total_cost'] == 112.5 + assert distribution['cost_per_minute'] == 0.25 # โ‚ฌ0.25 per minute + + # 5. Generate final summary + final_summary = service.generate_daily_summary(today) + + # Verify complete summary + assert final_summary['worktime']['total_hours'] == 7.5 + assert final_summary['worktime']['issues_worked'] == 2 + assert final_summary['worktime']['cost_allocated'] == 112.5 + + assert final_summary['activities']['total_activities'] == 3 + assert final_summary['activities']['unique_issues'] == 2 + + assert final_summary['costs']['daily_total'] == 112.5 + assert final_summary['costs']['has_cost_allocation'] is True + assert len(final_summary['costs']['issue_costs']) == 2 + + # Verify recommendations are helpful + recommendations = final_summary['recommendations'] + assert len(recommendations) >= 0 # Should have reasonable recommendations + + def test_multi_day_period_summary(self): + """Test period summary across multiple days.""" + service = DayWrapUpService(self.db_path) + + # Create data across multiple days + dates = [date.today() - timedelta(days=i) for i in range(3)] # Last 3 days + + for i, test_date in enumerate(dates): + # Log different amounts of work each day + hours = 6 + i * 2 # 6, 8, 10 hours + minutes = hours * 60 + + service.worktime_tracker.log_worktime(124, minutes // 2, work_date=test_date, description=f"Day {i+1} main work") + service.worktime_tracker.log_worktime(125 + i, minutes // 2, work_date=test_date, description=f"Day {i+1} side work") + + # Log activities + from markitect.issues.activity_tracker import ActivityType + service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=test_date) + service.activity_tracker.log_activity(125 + i, ActivityType.MODIFIED, activity_date=test_date) + + # Generate period report + start_date = dates[-1] # Oldest date + end_date = dates[0] # Most recent date + + worktime_report = service.worktime_tracker.get_worktime_report( + start_date=start_date, + end_date=end_date + ) + + # Verify period data + assert worktime_report['total_entries'] == 6 # 2 entries per day * 3 days + assert worktime_report['total_time']['total_minutes'] == 1440 # 6+8+10 = 24 hours + assert worktime_report['unique_issues'] == 4 # Issues 124, 125, 126, 127 + assert worktime_report['unique_dates'] == 3 + + # Verify daily averages + expected_avg = 1440 / 3 # 480 minutes per day on average + assert abs(worktime_report['average_minutes_per_day'] - expected_avg) < 1 + + def test_error_handling_and_edge_cases(self): + """Test error handling and edge cases.""" + service = DayWrapUpService(self.db_path) + today = date.today() + + # Test estimation with no activities + estimation = service.perform_auto_estimation(today, 8.0) + assert estimation['estimated'] is False + assert "No active issues found" in estimation['reason'] + + # Test cost distribution with no worktime + distribution = service.distribute_daily_costs(today, Decimal('100.00')) + assert 'message' in distribution + assert "No worktime entries found" in distribution['message'] + + # Test summary generation with partial data + from markitect.issues.activity_tracker import ActivityType + service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today) + + summary = service.generate_daily_summary(today) + assert summary['worktime']['total_minutes'] == 0 # No worktime logged + assert summary['activities']['total_activities'] == 1 # But activity exists + assert "No worktime logged" in ' '.join(summary['recommendations']) + + # Test recommendations for edge cases + service.worktime_tracker.log_worktime(124, 720, work_date=today) # 12 hours - excessive + summary = service.generate_daily_summary(today) + assert any("High worktime logged" in rec for rec in summary['recommendations']) \ No newline at end of file