From 961982715a0df504ed791688b8d48fae43431324 Mon Sep 17 00:00:00 2001 From: Dhruv Agrawal Date: Sat, 4 Oct 2025 12:29:42 +0530 Subject: [PATCH 1/2] feat: add time tracking functionality... Issue#46 --- tix/cli.py | 237 +++++++++++++++++++++++++++++++++++- tix/commands/stats.py | 43 ++++++- tix/models.py | 78 +++++++++++- tix/storage/json_storage.py | 10 +- 4 files changed, 357 insertions(+), 11 deletions(-) diff --git a/tix/cli.py b/tix/cli.py index 2943947..2e6e3af 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -15,6 +15,37 @@ 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" + @click.group(invoke_without_command=True) @click.version_option(version="0.8.0", prog_name="tix") @@ -40,7 +71,8 @@ def cli(ctx): @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 @@ -48,6 +80,14 @@ def add(task, priority, tag, attach, link): console.print("[red]✗[/red] Task text cannot be empty") sys.exit(1) + 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') @@ -56,7 +96,7 @@ def add(task, priority, tag, attach, link): default_tags = CONFIG.get('defaults', {}).get('tags', []) all_tags = list(set(list(tag) + default_tags)) - new_task = storage.add_task(task, priority, all_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) @@ -91,6 +131,8 @@ def add(task, priority, tag, attach, link): 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() @@ -854,6 +896,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() 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 4d8445d..7036390 100644 --- a/tix/storage/json_storage.py +++ b/tix/storage/json_storage.py @@ -65,11 +65,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) -> Task: + def add_task(self, text: str, priority: str = 'medium', tags: List[str] = None, estimate: int = None) -> 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) From b57029831abfaf76361b770e63b4793d84062821 Mon Sep 17 00:00:00 2001 From: Dhruv Agrawal Date: Sat, 4 Oct 2025 17:10:16 +0530 Subject: [PATCH 2/2] fix: update filter command test for new command group syntax --- tests/test_cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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