Files
markitect-main/markitect/finance/worktime_commands.py
tegwick 8d90785fb8 feat: complete issue #123 - Issue #123
Automated issue wrap-up including:
- Implementation completion verification
- Test execution and validation
- Cost tracking and note generation
- Repository state commit

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 04:19:57 +02:00

510 lines
19 KiB
Python

"""
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()