""" CLI commands for worktime tracking and cost distribution. This module provides command-line interface for logging, viewing, and managing worktime entries, estimating daily effort, and distributing costs based on time allocation across issues. """ import click from datetime import datetime, date, timedelta from typing import List, Optional from tabulate import tabulate import json from .worktime_tracker import WorktimeTracker, WorktimeEntry @click.group() def worktime(): """Worktime tracking and cost distribution commands.""" pass @worktime.command() @click.argument('issue_id', type=int) @click.argument('duration', type=str) @click.option('--date', '-d', type=click.DateTime(formats=['%Y-%m-%d']), help='Work date (defaults to today)') @click.option('--start', '-s', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']), help='Start time (e.g., 09:30 or 2025-10-04 09:30)') @click.option('--end', '-e', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']), help='End time (e.g., 11:30 or 2025-10-04 11:30)') @click.option('--description', '-m', help='Work description') @click.option('--type', 'entry_type', type=click.Choice(['manual', 'estimated', 'tracked']), default='manual', help='Entry type') def log(issue_id: int, duration: str, date: Optional[datetime], start: Optional[datetime], end: Optional[datetime], description: Optional[str], entry_type: str): """Log worktime for an issue. DURATION can be: - Minutes: 90, 120 - Hours and minutes: 1h30m, 2h15m, 1.5h - Hours only: 1h, 2.5h """ tracker = WorktimeTracker() # Parse duration try: duration_minutes = _parse_duration(duration) except ValueError as e: click.echo(f"āŒ Invalid duration format: {e}", err=True) raise click.Abort() # Import date module locally to avoid conflict with parameter name from datetime import date as date_module work_date = date.date() if date else None try: entry_id = tracker.log_worktime( issue_id=issue_id, duration_minutes=duration_minutes, work_date=work_date, start_time=start, end_time=end, description=description, entry_type=entry_type ) click.echo(f"āœ… Logged {duration_minutes}min worktime for issue #{issue_id} (ID: {entry_id})") if description: click.echo(f" Description: {description}") # Show total time for the day summary = tracker.get_daily_summary(work_date or date_module.today()) if summary: hours = summary.total_minutes // 60 minutes = summary.total_minutes % 60 click.echo(f"šŸ“Š Daily total: {hours}h {minutes}m across {summary.issue_count} issues") except Exception as e: click.echo(f"āŒ Error logging worktime: {e}", err=True) raise click.Abort() @worktime.command() @click.option('--issue', type=int, help='Filter by specific issue ID') @click.option('--date', type=click.DateTime(formats=['%Y-%m-%d']), help='Filter by specific date') @click.option('--start-date', type=click.DateTime(formats=['%Y-%m-%d']), help='Start date for range filter') @click.option('--end-date', type=click.DateTime(formats=['%Y-%m-%d']), help='End date for range filter') @click.option('--limit', '-l', type=int, default=20, help='Maximum number of entries to show') @click.option('--format', 'output_format', type=click.Choice(['table', 'json']), default='table', help='Output format') def list(issue: Optional[int], date: Optional[datetime], start_date: Optional[datetime], end_date: Optional[datetime], limit: int, output_format: str): """List worktime entries with optional filtering.""" tracker = WorktimeTracker() try: entries = tracker.get_worktime_entries( issue_id=issue, work_date=date.date() if date else None, start_date=start_date.date() if start_date else None, end_date=end_date.date() if end_date else None, limit=limit ) if not entries: click.echo("šŸ“ No worktime entries found for the specified criteria") return if output_format == 'json': entry_data = [] for entry in entries: data = { 'id': entry.id, 'issue_id': entry.issue_id, 'work_date': entry.work_date.isoformat() if entry.work_date else None, 'start_time': entry.start_time.isoformat() if entry.start_time else None, 'end_time': entry.end_time.isoformat() if entry.end_time else None, 'duration_minutes': entry.duration_minutes, 'duration_formatted': _format_duration(entry.duration_minutes), 'description': entry.description, 'entry_type': entry.entry_type, 'created_at': entry.created_at.isoformat() if entry.created_at else None } entry_data.append(data) click.echo(json.dumps(entry_data, indent=2)) else: # Table format click.echo(f"\nā° Worktime Entries ({len(entries)} found)\n") headers = ['ID', 'Issue', 'Date', 'Duration', 'Start', 'End', 'Type', 'Description'] rows = [] for entry in entries: rows.append([ entry.id, f"#{entry.issue_id}", entry.work_date.strftime('%Y-%m-%d') if entry.work_date else 'N/A', _format_duration(entry.duration_minutes), entry.start_time.strftime('%H:%M') if entry.start_time else 'N/A', entry.end_time.strftime('%H:%M') if entry.end_time else 'N/A', entry.entry_type.title(), (entry.description[:30] + '...') if entry.description and len(entry.description) > 30 else (entry.description or '') ]) click.echo(tabulate(rows, headers=headers, tablefmt='grid')) if len(entries) == limit: click.echo(f"\nšŸ’” Showing {limit} most recent entries. Use --limit to see more.") except Exception as e: click.echo(f"āŒ Error retrieving entries: {e}", err=True) raise click.Abort() @worktime.command() @click.argument('date', type=click.DateTime(formats=['%Y-%m-%d'])) @click.option('--format', 'output_format', type=click.Choice(['table', 'json']), default='table', help='Output format') def daily(date: datetime, output_format: str): """Show daily worktime summary for a specific date.""" from datetime import date as date_module tracker = WorktimeTracker() try: summary = tracker.get_daily_summary(date.date()) if not summary: click.echo(f"šŸ“… No worktime entries found for {date.date()}") return if output_format == 'json': data = { 'work_date': summary.work_date.isoformat(), 'total_minutes': summary.total_minutes, 'total_formatted': _format_duration(summary.total_minutes), 'issue_count': summary.issue_count, 'cost_per_minute': float(summary.cost_per_minute) if summary.cost_per_minute else None, 'total_cost_allocated': float(summary.total_cost_allocated) if summary.total_cost_allocated else None, 'entries': [ { 'issue_id': entry.issue_id, 'duration_minutes': entry.duration_minutes, 'duration_formatted': _format_duration(entry.duration_minutes), 'description': entry.description, 'entry_type': entry.entry_type } for entry in summary.entries ] } click.echo(json.dumps(data, indent=2)) else: # Table format click.echo(f"\nšŸ“… Daily Summary for {summary.work_date}\n") click.echo(f"Total Time: {_format_duration(summary.total_minutes)} ({summary.total_minutes} minutes)") click.echo(f"Issues Worked: {summary.issue_count}") if summary.cost_per_minute: click.echo(f"Cost per Minute: €{summary.cost_per_minute:.4f}") if summary.total_cost_allocated: click.echo(f"Total Cost Allocated: €{summary.total_cost_allocated:.2f}") click.echo("\nBreakdown by Issue:") # Group by issue issue_breakdown = {} for entry in summary.entries: if entry.issue_id not in issue_breakdown: issue_breakdown[entry.issue_id] = 0 issue_breakdown[entry.issue_id] += entry.duration_minutes headers = ['Issue', 'Time', 'Percentage'] rows = [] for issue_id, minutes in sorted(issue_breakdown.items()): percentage = (minutes / summary.total_minutes) * 100 rows.append([ f"#{issue_id}", _format_duration(minutes), f"{percentage:.1f}%" ]) click.echo(tabulate(rows, headers=headers, tablefmt='grid')) except Exception as e: click.echo(f"āŒ Error generating summary: {e}", err=True) raise click.Abort() @worktime.command() @click.argument('date', type=click.DateTime(formats=['%Y-%m-%d'])) @click.argument('hours', type=float) @click.option('--issues', '-i', multiple=True, type=int, help='Issues that were worked on (can specify multiple)') @click.option('--method', type=click.Choice(['equal', 'activity_based']), default='equal', help='Distribution method') def estimate(date: datetime, hours: float, issues: List[int], method: str): """Estimate worktime distribution for a day.""" tracker = WorktimeTracker() try: # Convert issues tuple to list safely issues_list = [int(issue) for issue in issues] if issues else None result = tracker.estimate_daily_worktime( work_date=date.date(), total_hours=hours, issues=issues_list, distribution_method=method ) click.echo(f"\nšŸ“Š Worktime Estimation for {result['work_date']}") click.echo(f"Total Hours: {hours}h ({result['total_minutes']} minutes)") click.echo(f"Distribution Method: {method}") click.echo(f"Issues: {result['issues_count']}") click.echo("\nEstimated Time per Issue:") headers = ['Issue', 'Time', 'Percentage'] rows = [] for issue_id, minutes in result['issue_estimates'].items(): percentage = (minutes / result['total_minutes']) * 100 rows.append([ f"#{issue_id}", _format_duration(minutes), f"{percentage:.1f}%" ]) click.echo(tabulate(rows, headers=headers, tablefmt='grid')) click.echo(f"\nāœ… Created {len(result['issue_estimates'])} estimated worktime entries") except Exception as e: click.echo(f"āŒ Error creating estimation: {e}", err=True) raise click.Abort() @worktime.command() @click.argument('date', type=click.DateTime(formats=['%Y-%m-%d'])) @click.argument('total_cost', type=float) @click.option('--period-id', type=int, help='Cost period ID for tracking') def distribute(date: datetime, total_cost: float, period_id: Optional[int]): """Distribute daily costs based on time allocation.""" from datetime import date as date_module tracker = WorktimeTracker() try: from decimal import Decimal result = tracker.distribute_daily_costs( work_date=date.date(), total_daily_cost=Decimal(str(total_cost)), period_id=period_id ) if 'message' in result: click.echo(f"āš ļø {result['message']}") return click.echo(f"\nšŸ’° Cost Distribution for {result['work_date']}") click.echo(f"Total Cost: €{result['total_cost']:.2f}") click.echo(f"Total Time: {_format_duration(result['total_minutes'])}") click.echo(f"Cost per Minute: €{result['cost_per_minute']:.4f}") click.echo("\nCost Allocation by Issue:") headers = ['Issue', 'Time', 'Percentage', 'Cost Allocated'] rows = [] for issue_id, distribution in result['distributions'].items(): rows.append([ f"#{issue_id}", _format_duration(distribution['minutes']), f"{distribution['percentage']:.1f}%", f"€{distribution['cost_allocated']:.2f}" ]) click.echo(tabulate(rows, headers=headers, tablefmt='grid')) if period_id: click.echo(f"\nāœ… Cost distribution logged to period #{period_id}") except Exception as e: click.echo(f"āŒ Error distributing costs: {e}", err=True) raise click.Abort() @worktime.command() @click.option('--start-date', type=click.DateTime(formats=['%Y-%m-%d']), help='Report start date (defaults to 30 days ago)') @click.option('--end-date', type=click.DateTime(formats=['%Y-%m-%d']), help='Report end date (defaults to today)') @click.option('--issue', type=int, help='Filter by specific issue ID') @click.option('--format', 'output_format', type=click.Choice(['table', 'json']), default='table', help='Output format') def report(start_date: Optional[datetime], end_date: Optional[datetime], issue: Optional[int], output_format: str): """Generate comprehensive worktime report.""" tracker = WorktimeTracker() try: result = tracker.get_worktime_report( start_date=start_date.date() if start_date else None, end_date=end_date.date() if end_date else None, issue_id=issue ) if output_format == 'json': click.echo(json.dumps(result, indent=2)) return # Table format click.echo(f"\nšŸ“ˆ Worktime Report") click.echo(f"Period: {result['period']}") click.echo(f"Total Entries: {result['total_entries']}") click.echo(f"Total Time: {result['total_time']['hours']}h {result['total_time']['minutes']}m") click.echo(f"Unique Issues: {result['unique_issues']}") click.echo(f"Unique Dates: {result['unique_dates']}") if result['unique_dates'] > 0: avg_minutes = result['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") if result.get('issue_breakdown'): click.echo("\nTime by Issue:") headers = ['Issue', 'Total Time', 'Entries', 'Days', 'Avg/Day'] rows = [] for issue_id, data in sorted(result['issue_breakdown'].items()): total_time = _format_duration(data['total_minutes']) avg_per_day = data['total_minutes'] / data['unique_dates'] avg_formatted = _format_duration(int(avg_per_day)) rows.append([ f"#{issue_id}", total_time, data['entry_count'], data['unique_dates'], avg_formatted ]) click.echo(tabulate(rows, headers=headers, tablefmt='grid')) except Exception as e: click.echo(f"āŒ Error generating report: {e}", err=True) raise click.Abort() @worktime.command() @click.argument('entry_id', type=int) @click.confirmation_option(prompt='Are you sure you want to delete this worktime entry?') def delete(entry_id: int): """Delete a worktime entry.""" tracker = WorktimeTracker() try: if tracker.delete_worktime_entry(entry_id): click.echo(f"āœ… Deleted worktime entry #{entry_id}") else: click.echo(f"āŒ Worktime entry #{entry_id} not found") raise click.Abort() except Exception as e: click.echo(f"āŒ Error deleting entry: {e}", err=True) raise click.Abort() @worktime.command() @click.argument('entry_id', type=int) @click.option('--duration', type=str, help='New duration (e.g., 90, 1h30m, 2.5h)') @click.option('--description', help='New description') @click.option('--start', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']), help='New start time') @click.option('--end', type=click.DateTime(formats=['%H:%M', '%Y-%m-%d %H:%M']), help='New end time') def update(entry_id: int, duration: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime]): """Update a worktime entry.""" tracker = WorktimeTracker() try: duration_minutes = None if duration: duration_minutes = _parse_duration(duration) success = tracker.update_worktime_entry( entry_id=entry_id, duration_minutes=duration_minutes, description=description, start_time=start, end_time=end ) if success: click.echo(f"āœ… Updated worktime entry #{entry_id}") else: click.echo(f"āŒ Worktime entry #{entry_id} not found or no changes made") raise click.Abort() except ValueError as e: click.echo(f"āŒ Invalid duration format: {e}", err=True) raise click.Abort() except Exception as e: click.echo(f"āŒ Error updating entry: {e}", err=True) raise click.Abort() def _parse_duration(duration_str: str) -> int: """Parse duration string into minutes.""" duration_str = duration_str.lower().strip() # Handle pure numbers (assume minutes) if duration_str.isdigit(): return int(duration_str) # Handle decimal hours (e.g., 1.5h, 2.25h) if duration_str.endswith('h') and '.' in duration_str: try: hours = float(duration_str[:-1]) return int(hours * 60) except ValueError: pass # Handle hours and minutes (e.g., 1h30m, 2h15m) if 'h' in duration_str: parts = duration_str.split('h') hours = int(parts[0]) if parts[0] else 0 minutes = 0 if len(parts) > 1 and parts[1]: minute_part = parts[1].replace('m', '').strip() if minute_part: minutes = int(minute_part) return hours * 60 + minutes # Handle minutes only (e.g., 90m) if duration_str.endswith('m'): return int(duration_str[:-1]) raise ValueError(f"Unable to parse duration: {duration_str}. Use formats like: 90, 1h30m, 2.5h") def _format_duration(minutes: int) -> str: """Format minutes into human-readable duration.""" if minutes < 60: return f"{minutes}m" hours = minutes // 60 remaining_minutes = minutes % 60 if remaining_minutes == 0: return f"{hours}h" else: return f"{hours}h{remaining_minutes}m" if __name__ == '__main__': worktime()