refactor: remove obsolete issue management system in favor of issue-facade
Some checks failed
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled

Complete cleanup of the legacy TDD AI and issue management system, establishing clear separation of concerns as requested. All issue handling is now provided by the standalone issue-facade system.

Removed components:
- TDD AI framework (tddai/ directory and tddai_cli.py)
- Legacy issue management CLI commands and services
- Issue-related Makefile targets and helper commands
- Obsolete tests and infrastructure dependencies
- Finance modules that depended on the old issue system

Updated:
- Makefile: Removed issue-*, tdd-*, and test-from-issue commands
- CLI framework: Simplified to core functionality only
- Documentation: Added deprecation notice for old config system

The issue-facade now serves as the universal CLI for issue tracking,
providing backend-agnostic interface to GitHub, GitLab, Gitea, and
local SQLite storage as documented in issue-facade/README.md.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-24 21:25:04 +02:00
parent cb94c92fc0
commit a8e5b4b044
58 changed files with 11 additions and 14628 deletions

View File

@@ -1,566 +0,0 @@
"""
Cost Allocation Engine for MarkiTect Issue Cost Distribution.
This module implements the core allocation engine that distributes operational
costs across active issues using the defined algorithm from Issue #88.
The engine handles:
- Equal distribution of costs across active issues in a period
- Loss carried forward when no active issues exist
- Transaction audit trail creation
- Edge case handling and validation
- Integration with period management and activity tracking
"""
import sqlite3
from datetime import date, datetime
from decimal import Decimal
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
from .models import FinanceModels
from .cost_manager import CostItemManager
from .period_manager import PeriodManager, Period, PeriodStatus
from ..issues.activity_tracker import IssueActivityTracker, ActivityType
class AllocationStatus(Enum):
"""Status enumeration for allocation operations."""
SUCCESS = "success"
NO_ACTIVE_ISSUES = "no_active_issues"
NO_COSTS_TO_ALLOCATE = "no_costs_to_allocate"
PERIOD_CLOSED = "period_closed"
ERROR = "error"
@dataclass
class AllocationResult:
"""Result of a cost allocation operation."""
status: AllocationStatus
period_id: int
total_costs: Decimal = Decimal('0.00')
active_issues: List[int] = None
cost_per_issue: Decimal = Decimal('0.00')
allocations_created: int = 0
transactions_created: int = 0
loss_carried_forward: Decimal = Decimal('0.00')
message: str = ""
def __post_init__(self):
if self.active_issues is None:
self.active_issues = []
@dataclass
class IssueAllocation:
"""Represents a cost allocation to a specific issue."""
issue_id: int
allocated_amount: Decimal
allocation_date: date
period_id: int
transaction_id: Optional[int] = None
class TransactionManager:
"""Manages cost transaction audit trails for allocations."""
def __init__(self, db_path: str):
"""
Initialize transaction manager.
Args:
db_path: Path to the SQLite database
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
def create_allocation_transaction(
self,
period_id: int,
amount: Decimal,
issue_id: int,
transaction_date: date,
description: str
) -> int:
"""
Create a cost allocation transaction record.
Args:
period_id: ID of the cost period
amount: Amount allocated to the issue
issue_id: ID of the issue receiving allocation
transaction_date: Date of the transaction
description: Description of the allocation
Returns:
ID of the created transaction
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, issue_id,
transaction_date, description)
VALUES (?, 'cost_allocated', ?, ?, ?, ?)
''', (period_id, float(amount), issue_id, transaction_date.isoformat() if hasattr(transaction_date, 'isoformat') else transaction_date, description))
return cursor.lastrowid
def create_loss_forward_transaction(
self,
from_period_id: int,
to_period_id: int,
amount: Decimal,
transaction_date: date,
description: str
) -> int:
"""
Create a loss carried forward transaction.
Args:
from_period_id: Source period ID
to_period_id: Destination period ID
amount: Amount being carried forward
transaction_date: Date of the transaction
description: Description of the carry forward
Returns:
ID of the created transaction
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, transaction_date, description)
VALUES (?, 'loss_forward', ?, ?, ?)
''', (to_period_id, float(amount), transaction_date.isoformat() if hasattr(transaction_date, 'isoformat') else transaction_date, description))
return cursor.lastrowid
class AllocationEngine:
"""
Core cost allocation engine for distributing operational costs to active issues.
Implements the algorithm defined in Issue #88:
1. Calculate total costs for the period (monthly + one-time + carried forward)
2. Identify active issues (created/modified during period)
3. Distribute costs equally among active issues
4. Handle edge cases (no active issues -> carry forward loss)
5. Create audit trail transactions
6. Update period statistics
"""
def __init__(self, db_path: str = "markitect.db"):
"""
Initialize the allocation engine.
Args:
db_path: Path to the SQLite database
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
self.cost_manager = CostItemManager(db_path)
self.period_manager = PeriodManager(db_path)
self.activity_tracker = IssueActivityTracker(db_path)
self.transaction_manager = TransactionManager(db_path)
# Ensure database schema is initialized
self.finance_models.initialize_finance_schema()
def allocate_period_costs(self, period_id: int) -> AllocationResult:
"""
Allocate costs for a specific period to active issues.
Args:
period_id: ID of the period to process
Returns:
AllocationResult with operation details and status
"""
try:
# Get period details
period = self._get_period(period_id)
if not period:
return AllocationResult(
status=AllocationStatus.ERROR,
period_id=period_id,
message=f"Period {period_id} not found"
)
# Check if period is already closed
if period.status == PeriodStatus.CLOSED.value:
return AllocationResult(
status=AllocationStatus.PERIOD_CLOSED,
period_id=period_id,
message=f"Period {period_id} is already closed"
)
# Set period status to calculating
self._update_period_status(period_id, PeriodStatus.CALCULATING)
# Step 1: Calculate total costs for period
total_costs = self._calculate_period_total_costs(period)
if total_costs == Decimal('0.00'):
self._update_period_status(period_id, PeriodStatus.CLOSED)
return AllocationResult(
status=AllocationStatus.NO_COSTS_TO_ALLOCATE,
period_id=period_id,
total_costs=total_costs,
message="No costs to allocate for this period"
)
# Step 2: Identify active issues for the period
active_issues = self._get_active_issues_for_period(period)
if not active_issues:
# No active issues - carry forward loss to next period
next_period_id = self._get_or_create_next_period(period)
if next_period_id:
self._carry_forward_loss(period_id, next_period_id, total_costs)
# Update period and close
self._update_period_totals(period_id, total_costs, 0, Decimal('0.00'), total_costs)
self._update_period_status(period_id, PeriodStatus.CLOSED)
return AllocationResult(
status=AllocationStatus.NO_ACTIVE_ISSUES,
period_id=period_id,
total_costs=total_costs,
active_issues=[],
loss_carried_forward=total_costs,
message=f"No active issues found. Carried forward €{total_costs:.2f} to next period"
)
# Step 3: Calculate cost per issue (equal distribution)
cost_per_issue = total_costs / len(active_issues)
# Step 4: Create allocations and transactions
allocations_created = 0
transactions_created = 0
allocation_date = date.today()
for issue_id in active_issues:
# Create allocation record
allocation_id = self._create_issue_allocation(
issue_id, period_id, cost_per_issue, allocation_date
)
if allocation_id:
allocations_created += 1
# Create audit transaction
transaction_id = self.transaction_manager.create_allocation_transaction(
period_id=period_id,
amount=cost_per_issue,
issue_id=issue_id,
transaction_date=allocation_date,
description=f"Cost allocation for period {period.period_start} to {period.period_end}"
)
if transaction_id:
transactions_created += 1
# Link transaction to allocation
self._update_allocation_transaction_id(allocation_id, transaction_id)
# Step 5: Update period totals
self._update_period_totals(
period_id, total_costs, len(active_issues), cost_per_issue, Decimal('0.00')
)
# Step 6: Close the period
self._update_period_status(period_id, PeriodStatus.CLOSED)
return AllocationResult(
status=AllocationStatus.SUCCESS,
period_id=period_id,
total_costs=total_costs,
active_issues=active_issues,
cost_per_issue=cost_per_issue,
allocations_created=allocations_created,
transactions_created=transactions_created,
message=f"Successfully allocated €{total_costs:.2f} across {len(active_issues)} issues"
)
except Exception as e:
# Reset period status on error
self._update_period_status(period_id, PeriodStatus.OPEN)
return AllocationResult(
status=AllocationStatus.ERROR,
period_id=period_id,
message=f"Allocation failed: {str(e)}"
)
def get_issue_allocations(self, issue_id: int) -> List[Dict[str, Any]]:
"""
Get all cost allocations for a specific issue.
Args:
issue_id: ID of the issue
Returns:
List of allocation records
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT
ica.id,
ica.issue_id,
ica.period_id,
ica.allocated_amount,
ica.allocation_date,
ica.transaction_id,
cp.period_start,
cp.period_end,
cp.period_type
FROM issue_cost_allocations ica
JOIN cost_periods cp ON ica.period_id = cp.id
WHERE ica.issue_id = ?
ORDER BY ica.allocation_date DESC
''', (issue_id,))
rows = cursor.fetchall()
allocations = []
for row in rows:
allocation = {
'id': row[0],
'issue_id': row[1],
'period_id': row[2],
'allocated_amount': float(row[3]),
'allocation_date': row[4],
'transaction_id': row[5],
'period_start': row[6],
'period_end': row[7],
'period_type': row[8]
}
allocations.append(allocation)
return allocations
def get_period_allocations(self, period_id: int) -> List[Dict[str, Any]]:
"""
Get all allocations for a specific period.
Args:
period_id: ID of the period
Returns:
List of allocation records
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT
ica.id,
ica.issue_id,
ica.allocated_amount,
ica.allocation_date,
ica.transaction_id
FROM issue_cost_allocations ica
WHERE ica.period_id = ?
ORDER BY ica.issue_id
''', (period_id,))
rows = cursor.fetchall()
allocations = []
for row in rows:
allocation = {
'id': row[0],
'issue_id': row[1],
'allocated_amount': float(row[2]),
'allocation_date': row[3],
'transaction_id': row[4]
}
allocations.append(allocation)
return allocations
def reverse_allocation(self, allocation_id: int) -> bool:
"""
Reverse a cost allocation (for corrections).
Args:
allocation_id: ID of the allocation to reverse
Returns:
True if successful, False otherwise
"""
try:
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Get allocation details
cursor.execute('''
SELECT issue_id, period_id, allocated_amount, transaction_id
FROM issue_cost_allocations
WHERE id = ?
''', (allocation_id,))
result = cursor.fetchone()
if not result:
return False
issue_id, period_id, amount, transaction_id = result
# Create reversal transaction using adjustment type (allows negative amounts)
with self.finance_models.get_connection() as conn2:
cursor2 = conn2.cursor()
cursor2.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, issue_id,
transaction_date, description)
VALUES (?, 'adjustment', ?, ?, ?, ?)
''', (period_id, float(-amount), issue_id, date.today().isoformat(), f"Reversal of allocation #{allocation_id}"))
reversal_transaction_id = cursor2.lastrowid
# Only delete if reversal transaction was created successfully
if reversal_transaction_id:
cursor.execute('DELETE FROM issue_cost_allocations WHERE id = ?', (allocation_id,))
return cursor.rowcount > 0
else:
return False
except Exception as e:
# Log the exception for debugging in tests
print(f"Reversal failed with exception: {e}")
return False
def _get_period(self, period_id: int) -> Optional[Period]:
"""Get period details by ID."""
period_data = self.period_manager.get_period_by_id(period_id)
if not period_data:
return None
# Convert dict to Period object
return Period(
id=period_data['id'],
period_start=datetime.strptime(period_data['period_start'], '%Y-%m-%d').date() if period_data['period_start'] else None,
period_end=datetime.strptime(period_data['period_end'], '%Y-%m-%d').date() if period_data['period_end'] else None,
period_type=period_data['period_type'],
status=period_data['status'],
total_costs=Decimal(str(period_data['total_costs'])),
active_issues_count=period_data['active_issues_count'],
cost_per_issue=Decimal(str(period_data['cost_per_issue'])),
loss_carried_forward=Decimal(str(period_data['loss_carried_forward'] or 0))
)
def _update_period_status(self, period_id: int, status: PeriodStatus):
"""Update period status."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE cost_periods SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(status.value, period_id)
)
def _calculate_period_total_costs(self, period: Period) -> Decimal:
"""Calculate total costs for a period including carried forward amounts."""
calculations = self.cost_manager.calculate_period_costs(
period.period_start, period.period_end
)
period_costs = calculations['total_period']
carried_forward = period.loss_carried_forward or Decimal('0.00')
return Decimal(str(period_costs)) + carried_forward
def _get_active_issues_for_period(self, period: Period) -> List[int]:
"""Get list of active issue IDs for a period."""
activities = self.activity_tracker.get_activities_by_period(
period.id,
activity_types=[
ActivityType.CREATED,
ActivityType.MODIFIED,
ActivityType.COMMENTED,
ActivityType.STATUS_CHANGED
]
)
# Get unique issue IDs
active_issues = list(set(activity.issue_id for activity in activities))
return active_issues
def _get_or_create_next_period(self, current_period: Period) -> Optional[int]:
"""Get or create the next period for loss carry forward."""
# For now, return None - next period creation will be handled separately
# This is a placeholder for future automatic period creation
return None
def _carry_forward_loss(self, from_period_id: int, to_period_id: int, amount: Decimal):
"""Carry forward loss to next period."""
# Update the destination period's carried forward amount
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE cost_periods
SET loss_carried_forward = loss_carried_forward + ?
WHERE id = ?
''', (float(amount), to_period_id))
# Create audit transaction
self.transaction_manager.create_loss_forward_transaction(
from_period_id=from_period_id,
to_period_id=to_period_id,
amount=amount,
transaction_date=date.today(),
description=f"Loss carried forward from period {from_period_id}"
)
def _create_issue_allocation(
self, issue_id: int, period_id: int, amount: Decimal, allocation_date: date
) -> Optional[int]:
"""Create an issue cost allocation record."""
try:
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (?, ?, ?, ?)
''', (issue_id, period_id, float(amount), allocation_date.isoformat() if hasattr(allocation_date, 'isoformat') else allocation_date))
return cursor.lastrowid
except sqlite3.IntegrityError:
# Allocation already exists for this issue/period
return None
def _update_allocation_transaction_id(self, allocation_id: int, transaction_id: int):
"""Link allocation to its audit transaction."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE issue_cost_allocations
SET transaction_id = ?
WHERE id = ?
''', (transaction_id, allocation_id))
def _update_period_totals(
self,
period_id: int,
total_costs: Decimal,
active_issues_count: int,
cost_per_issue: Decimal,
loss_carried_forward: Decimal
):
"""Update period summary statistics."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE cost_periods
SET total_costs = ?,
active_issues_count = ?,
cost_per_issue = ?,
loss_carried_forward = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (float(total_costs), active_issues_count, float(cost_per_issue), float(loss_carried_forward), period_id))

View File

@@ -1,507 +0,0 @@
"""
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.isoformat() if hasattr(target_date, 'isoformat') else 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.isoformat() if hasattr(target_date, 'isoformat') else 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.isoformat() if hasattr(target_date, 'isoformat') else 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()