diff --git a/tests/test_cli.py b/tests/test_cli.py index c672c4f..ceb50af 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -149,37 +149,37 @@ def test_filter_command(runner): with patch('tix.cli.storage', test_storage): # Test filter by priority - result = runner.invoke(cli, ['filter', '-p', 'high']) + result = runner.invoke(cli, ['filter', 'apply', '-p', 'high']) assert result.exit_code == 0 assert 'Completed task' in result.output assert 'Active task' not in result.output # Test filter by tag - result = runner.invoke(cli, ['filter', '-t', 'urgent']) + result = runner.invoke(cli, ['filter', 'apply', '-t', 'urgent']) assert result.exit_code == 0 assert 'Active task' in result.output assert 'Completed task' not in result.output # Test filter by completed status using long option - result = runner.invoke(cli, ['filter', '--completed']) + result = runner.invoke(cli, ['filter', 'apply', '--completed']) assert result.exit_code == 0 assert 'Completed task' in result.output assert 'Active task' not in result.output # Test filter by active status using long option - result = runner.invoke(cli, ['filter', '--active']) + result = runner.invoke(cli, ['filter', 'apply', '--active']) assert result.exit_code == 0 assert 'Active task' in result.output assert 'Completed task' not in result.output # Test filter by completed status using new short option - result = runner.invoke(cli, ['filter', '-c']) + result = runner.invoke(cli, ['filter', 'apply', '-c']) assert result.exit_code == 0 assert 'Completed task' in result.output assert 'Active task' not in result.output # Test filter by active status using new short option - result = runner.invoke(cli, ['filter', '-a']) + result = runner.invoke(cli, ['filter', 'apply', '-a']) assert result.exit_code == 0 assert 'Active task' in result.output assert 'Completed task' not in result.output diff --git a/tix/cli.py b/tix/cli.py index 1198a55..67cde38 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -8,25 +8,56 @@ from rich.table import Table from pathlib import Path from tix.storage.json_storage import TaskStorage -from tix.storage.context_storage import ContextStorage +# from tix.storage.context_storage import ContextStorage from tix.storage.history import HistoryManager from tix.storage.backup import create_backup, list_backups, restore_from_backup from tix.models import Task from rich.prompt import Prompt from rich.markdown import Markdown from datetime import datetime -from .storage import storage -from .config import CONFIG -from .context import context_storage +# from .storage import storage +# from .config import CONFIG +# from .context import context_storage console = Console() storage = TaskStorage() +def parse_time_estimate(time_str: str) -> int: + """Parse time string like '2h', '30m', '1h30m' into minutes""" + time_str = time_str.lower().strip() + total_minutes = 0 + + if 'h' in time_str: + parts = time_str.split('h') + hours = int(parts[0]) + total_minutes += hours * 60 + if len(parts) > 1 and parts[1]: + mins = parts[1].replace('m', '').strip() + if mins: + total_minutes += int(mins) + elif 'm' in time_str: + total_minutes = int(time_str.replace('m', '')) + else: + total_minutes = int(time_str) + + return total_minutes + + +def format_time_helper(minutes: int) -> str: + """Format minutes into human readable format""" + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + mins = minutes % 60 + if mins == 0: + return f"{hours}h" + return f"{hours}h {mins}m" + from typing import Optional, Dict, Any import json -context_storage = ContextStorage() +# context_storage = ContextStorage() history = HistoryManager() @click.group(invoke_without_command=True) @@ -148,7 +179,8 @@ def restore(backup_file, data_file, yes): @click.option('--tag', '-t', multiple=True, help='Add tags to task') @click.option('--attach', '-f', multiple=True, help='Attach file(s)') @click.option('--link', '-l', multiple=True, help='Attach URL(s)') -def add(task, priority, tag, attach, link): +@click.option('--estimate', '-e', help='Time estimate (e.g., 2h, 30m, 1h30m)') +def add(task, priority, tag, attach, link, estimate): """Add a new task""" from tix.config import CONFIG @@ -156,13 +188,23 @@ def add(task, priority, tag, attach, link): console.print("[red]✗[/red] Task text cannot be empty") sys.exit(1) - # merge default tags from config - default_tags = CONFIG.get('defaults', {}).get('tags', []) - tags = list(default_tags) + list(tag) - tags = list(dict.fromkeys(tags)) # preserve order, unique - - new_task = storage.add_task(task, priority, tags) + estimate_minutes = None + if estimate: + try: + estimate_minutes = parse_time_estimate(estimate) + except ValueError: + console.print("[red]✗[/red] Invalid time format. Use format like: 2h, 30m, 1h30m") + return + # Use config defaults if not specified + if priority is None: + priority = CONFIG.get('defaults', {}).get('priority', 'medium') + + # Merge config default tags with provided tags + default_tags = CONFIG.get('defaults', {}).get('tags', []) + all_tags = list(set(list(tag) + default_tags)) + + new_task = storage.add_task(task, priority, all_tags, estimate=estimate_minutes) # Handle attachments if attach: attachment_dir = Path.home() / ".tix" / "attachments" / str(new_task.id) @@ -189,10 +231,16 @@ def add(task, priority, tag, attach, link): color = {'high': 'red', 'medium': 'yellow', 'low': 'green'}[priority] console.print(f"[green]✔[/green] Added task #{new_task.id}: [{color}]{task}[/{color}]") - if tags: - console.print(f"[dim] Tags: {', '.join(tags)}[/dim]") - if attach or link: - console.print(f"[dim] Attachments/Links added[/dim]") + + # Show notification if enabled + if CONFIG.get('notifications', {}).get('on_creation', True): + if all_tags: + tag_color = CONFIG.get('colors', {}).get('tags', 'cyan') + console.print(f"[dim] Tags: [{tag_color}]{', '.join(all_tags)}[/{tag_color}][/dim]") + if attach or link: + console.print(f"[dim] Attachments/Links added[/dim]") + if estimate: + console.print(f"[dim] Estimated time: {new_task.format_time(estimate_minutes)}[/dim]") @cli.command() @@ -1136,6 +1184,197 @@ def config_edit(): console.print(f"[red]✗[/red] Failed to open editor: {e}") console.print(f"[dim]Try: export EDITOR=vim or export EDITOR=nano[/dim]") +@cli.command() +@click.argument('task_id', type=int) +def start(task_id): + """Start time tracking for a task""" + task = storage.get_task(task_id) + if not task: + console.print(f"[red]✗[/red] Task #{task_id} not found") + return + + if task.completed: + console.print(f"[yellow]![/yellow] Cannot start timer on completed task") + return + + for t in storage.load_tasks(): + if t.is_timer_running() and t.id != task_id: + console.print(f"[yellow]![/yellow] Task #{t.id} timer is already running") + console.print(f"[dim]Stop it first with: tix stop {t.id}[/dim]") + return + + if task.is_timer_running(): + duration = task.get_current_session_duration() + console.print(f"[yellow]![/yellow] Timer already running for {task.format_time(duration)}") + return + + try: + task.start_timer() + storage.update_task(task) + console.print(f"[green]⏱[/green] Started timer for task #{task_id}: {task.text}") + if task.estimate: + console.print(f"[dim] Estimated: {task.format_time(task.estimate)}[/dim]") + except ValueError as e: + console.print(f"[red]✗[/red] {str(e)}") + + +@cli.command() +@click.argument('task_id', type=int) +def stop(task_id): + """Stop time tracking for a task""" + task = storage.get_task(task_id) + if not task: + console.print(f"[red]✗[/red] Task #{task_id} not found") + return + + if not task.is_timer_running(): + console.print(f"[yellow]![/yellow] No timer running for task #{task_id}") + return + + try: + duration = task.stop_timer() + storage.update_task(task) + + console.print(f"[green]⏹[/green] Stopped timer for task #{task_id}") + console.print(f"[cyan] Session duration: {task.format_time(duration)}[/cyan]") + console.print(f"[dim] Total time spent: {task.format_time(task.time_spent)}[/dim]") + + if task.estimate: + remaining = task.get_time_remaining() + if remaining > 0: + console.print(f"[dim] Remaining: {task.format_time(remaining)}[/dim]") + elif remaining < 0: + console.print(f"[yellow] Over estimate by: {task.format_time(abs(remaining))}[/yellow]") + else: + console.print("[green] Completed within estimate![/green]") + except ValueError as e: + console.print(f"[red]✗[/red] {str(e)}") + + +@cli.command() +@click.argument('task_id', type=int, required=False) +def status(task_id): + """Show timer status for a task or all tasks""" + if task_id: + task = storage.get_task(task_id) + if not task: + console.print(f"[red]✗[/red] Task #{task_id} not found") + return + + if task.is_timer_running(): + duration = task.get_current_session_duration() + console.print(f"[green]⏱[/green] Timer running for task #{task_id}: {task.text}") + console.print(f"[cyan] Current session: {task.format_time(duration)}[/cyan]") + console.print(f"[dim] Total time: {task.format_time(task.time_spent + duration)}[/dim]") + else: + console.print(f"[dim]No timer running for task #{task_id}[/dim]") + if task.time_spent > 0: + console.print(f"[dim]Total time spent: {task.format_time(task.time_spent)}[/dim]") + else: + tasks = storage.load_tasks() + running_tasks = [t for t in tasks if t.is_timer_running()] + + if running_tasks: + for task in running_tasks: + duration = task.get_current_session_duration() + console.print(f"[green]⏱[/green] Task #{task.id}: {task.text}") + console.print(f"[cyan] Running for: {task.format_time(duration)}[/cyan]") + else: + console.print("[dim]No active timers[/dim]") + + +@cli.command() +@click.option('--period', '-p', type=click.Choice(['week', 'month', 'all']), + default='week', help='Time period for report') +def timereport(period): + """Generate time tracking report""" + from datetime import timedelta + + tasks = storage.load_tasks() + now = datetime.now() + + if period == 'week': + start_date = now - timedelta(days=7) + title = "Weekly Time Report" + elif period == 'month': + start_date = now - timedelta(days=30) + title = "Monthly Time Report" + else: + start_date = None + title = "All Time Report" + + relevant_tasks = [] + for task in tasks: + if task.time_spent > 0: + if start_date: + task_logs = [log for log in task.time_logs + if datetime.fromisoformat(log['ended_at']) >= start_date] + if task_logs: + relevant_tasks.append((task, sum(log['duration'] for log in task_logs))) + else: + relevant_tasks.append((task, task.time_spent)) + + if not relevant_tasks: + console.print(f"[dim]No time tracked in the {period}[/dim]") + return + + console.print(f"\n[bold cyan]{title}[/bold cyan]\n") + + table = Table() + table.add_column("ID", style="cyan", width=4) + table.add_column("Task") + table.add_column("Estimated", style="dim", width=10) + table.add_column("Spent", style="yellow", width=10) + table.add_column("Remaining", width=10) + table.add_column("Status", width=8) + + total_estimated = 0 + total_spent = 0 + + for task, time_in_period in relevant_tasks: + estimate_str = task.format_time(task.estimate) if task.estimate else "-" + spent_str = task.format_time(time_in_period) + + if task.estimate: + total_estimated += task.estimate + remaining = task.get_time_remaining() + if remaining > 0: + remaining_str = task.format_time(remaining) + status = "✓" if task.completed else "→" + status_color = "green" if task.completed else "blue" + elif remaining < 0: + remaining_str = f"+{task.format_time(abs(remaining))}" + status = "⚠" + status_color = "yellow" + else: + remaining_str = "0m" + status = "✓" + status_color = "green" + else: + remaining_str = "-" + status = "✓" if task.completed else "→" + status_color = "green" if task.completed else "blue" + + total_spent += time_in_period + + table.add_row( + str(task.id), + task.text[:40] + "..." if len(task.text) > 40 else task.text, + estimate_str, + spent_str, + remaining_str, + f"[{status_color}]{status}[/{status_color}]" + ) + + console.print(table) + + console.print(f"\n[bold]Summary:[/bold]") + if total_estimated > 0: + console.print(f" Total estimated: {format_time_helper(total_estimated)}") + console.print(f" Total spent: {format_time_helper(total_spent)}") + if total_estimated > 0: + efficiency = (total_estimated / max(total_spent, 1)) * 100 + console.print(f" Efficiency: {efficiency:.1f}%") if __name__ == '__main__': cli() \ No newline at end of file diff --git a/tix/commands/stats.py b/tix/commands/stats.py index 0ef0afc..ed517b4 100644 --- a/tix/commands/stats.py +++ b/tix/commands/stats.py @@ -9,7 +9,7 @@ def show_stats(storage): - """Display comprehensive task statistics""" + """Display comprehensive task statistics including time tracking""" tasks = storage.load_tasks() if not tasks: @@ -28,6 +28,14 @@ def show_stats(storage): all_tags.extend(task.tags) tag_counts = Counter(all_tags) + # Time tracking stats + tasks_with_estimates = [t for t in tasks if t.estimate] + tasks_with_time = [t for t in tasks if t.time_spent > 0] + total_estimated = sum(t.estimate for t in tasks_with_estimates) + total_spent = sum(t.time_spent for t in tasks_with_time) + + running_timers = [t for t in tasks if t.is_timer_running()] + # Time analysis today = datetime.now().date() today_completed = len([ @@ -49,6 +57,19 @@ def show_stats(storage): • 🟡 Medium: {priority_counts.get('medium', 0)} • 🟢 Low: {priority_counts.get('low', 0)} +[bold]Time Tracking:[/bold] + • Tasks with estimates: {len(tasks_with_estimates)} + • Tasks with tracked time: {len(tasks_with_time)}""" + + if total_estimated > 0: + stats_text += f"\n • Total estimated: {format_time(total_estimated)}" + if total_spent > 0: + stats_text += f"\n • Total time spent: {format_time(total_spent)}" + if running_timers: + stats_text += f"\n • [green]Active timers: {len(running_timers)}[/green]" + + stats_text += f""" + [bold]Today's Progress:[/bold] • Completed today: {today_completed} task(s) @@ -75,4 +96,22 @@ def show_stats(storage): "Overall", total=len(tasks), completed=len(completed) - ) \ No newline at end of file + ) + + # Show active timers + if running_timers: + console.print("\n[bold]Active Timers:[/bold]") + for task in running_timers: + duration = task.get_current_session_duration() + console.print(f" • Task #{task.id}: {task.text} - [cyan]{format_time(duration)}[/cyan]") + + +def format_time(minutes: int) -> str: + """Format minutes into human readable format""" + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + mins = minutes % 60 + if mins == 0: + return f"{hours}h" + return f"{hours}h {mins}m" \ No newline at end of file diff --git a/tix/models.py b/tix/models.py index c8e2467..1ea9e1b 100644 --- a/tix/models.py +++ b/tix/models.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Dict @dataclass class Task: @@ -13,7 +13,13 @@ class Task: completed_at: Optional[str] = None tags: List[str] = field(default_factory=list) attachments: List[str] = field(default_factory=list) - links: List[str] = field(default_factory=list) + links: List[str] = field(default_factory=list) + + # Time tracking fields + estimate: Optional[int] = None + time_spent: int = 0 + started_at: Optional[str] = None + time_logs: List[Dict] = field(default_factory=list) def to_dict(self) -> dict: """Convert task to dictionary for JSON serialization""" @@ -27,6 +33,10 @@ def to_dict(self) -> dict: 'tags': self.tags, 'attachments': self.attachments, 'links': self.links, + 'estimate': self.estimate, + 'time_spent': self.time_spent, + 'started_at': self.started_at, + 'time_logs': self.time_logs } @classmethod @@ -40,8 +50,12 @@ def from_dict(cls, data: dict): created_at=data.get('created_at', datetime.now().isoformat()), completed_at=data.get('completed_at'), tags=data.get('tags', []), - attachments=data.get('attachments', []), - links=data.get('links', []) + attachments=data.get('attachments', []), + links=data.get('links', []), + estimate=data.get('estimate'), + time_spent=data.get('time_spent', 0), + started_at=data.get('started_at'), + time_logs=data.get('time_logs', []) ) def mark_done(self): @@ -52,4 +66,58 @@ def mark_done(self): def add_tag(self, tag: str): """Add a tag to the task""" if tag not in self.tags: - self.tags.append(tag) \ No newline at end of file + self.tags.append(tag) + + def start_timer(self): + """Start tracking time for this task""" + if self.started_at: + raise ValueError("Timer already running for this task") + self.started_at = datetime.now().isoformat() + + def stop_timer(self): + """Stop tracking time and log the duration""" + if not self.started_at: + raise ValueError("Timer not running for this task") + + start_time = datetime.fromisoformat(self.started_at) + end_time = datetime.now() + duration = int((end_time - start_time).total_seconds() / 60) + + self.time_logs.append({ + 'started_at': self.started_at, + 'ended_at': end_time.isoformat(), + 'duration': duration + }) + + self.time_spent += duration + self.started_at = None + + return duration + + def is_timer_running(self) -> bool: + """Check if timer is currently running""" + return self.started_at is not None + + def get_current_session_duration(self) -> int: + """Get duration of current timer session in minutes""" + if not self.started_at: + return 0 + start_time = datetime.fromisoformat(self.started_at) + duration = int((datetime.now() - start_time).total_seconds() / 60) + return duration + + def format_time(self, minutes: int) -> str: + """Format minutes into human readable format""" + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + mins = minutes % 60 + if mins == 0: + return f"{hours}h" + return f"{hours}h {mins}m" + + def get_time_remaining(self) -> Optional[int]: + """Get remaining time based on estimate""" + if not self.estimate: + return None + return self.estimate - self.time_spent \ No newline at end of file diff --git a/tix/storage/json_storage.py b/tix/storage/json_storage.py index 88f5fae..3e2e661 100644 --- a/tix/storage/json_storage.py +++ b/tix/storage/json_storage.py @@ -89,11 +89,17 @@ def save_tasks(self, tasks: List[Task]): data["tasks"] = [task.to_dict() for task in tasks] self._write_data(data) - def add_task(self, text: str, priority: str = 'medium', tags: List[str] = None, due:str=None, is_global: bool = False, record_history: bool = True) -> Task: + def add_task(self, text: str, priority: str = 'medium', tags: List[str] = None, estimate: int = None, due: str = None, is_global: bool = False, record_history: bool = True) -> Task: """Add a new task and return it""" data = self._read_data() new_id = data["next_id"] - new_task = Task(id=new_id, text=text, priority=priority, tags=tags or []) + new_task = Task( + id=new_id, + text=text, + priority=priority, + tags=tags or [], + estimate=estimate, + ) data["tasks"].append(new_task.to_dict()) data["next_id"] = new_id + 1 self._write_data(data)