diff --git a/.gitignore b/.gitignore index 3bbc29f..974dc47 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.env/ # Spyder project settings .spyderproject @@ -182,9 +183,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/requirements.txt b/requirements.txt index d8690c0..22ee1c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pytest>=7.0.0 pytest-cov>=4.0.0 textual>=0.50.0 pyyaml>=6.0 +python-dateutil==2.9.0 \ No newline at end of file diff --git a/tix/cli.py b/tix/cli.py index 6d02994..02f576e 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -8,13 +8,20 @@ from rich.table import Table from pathlib import Path from tix.storage.json_storage import TaskStorage +from datetime import datetime, timedelta, date +from dateutil.relativedelta import relativedelta +import subprocess +import platform +import os +import sys +from importlib import import_module + # 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 @@ -26,7 +33,7 @@ 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]) @@ -39,7 +46,7 @@ def parse_time_estimate(time_str: str) -> int: total_minutes = int(time_str.replace('m', '')) else: total_minutes = int(time_str) - + return total_minutes @@ -180,7 +187,10 @@ def restore(backup_file, data_file, yes): @click.option('--attach', '-f', multiple=True, help='Attach file(s)') @click.option('--link', '-l', multiple=True, help='Attach URL(s)') @click.option('--estimate', '-e', help='Time estimate (e.g., 2h, 30m, 1h30m)') -def add(task, priority, tag, attach, link, estimate): +@click.option('--repeat', '-r', + type=click.Choice(['daily', 'monthly', 'weekly']), + help='Set task priority') +def add(task, priority, tag, attach, link, estimate, repeat): """Add a new task""" from tix.config import CONFIG @@ -199,11 +209,11 @@ def add(task, priority, tag, attach, link, estimate): # 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: @@ -227,11 +237,16 @@ def add(task, priority, tag, attach, link, estimate): new_task.links = [] new_task.links.extend(link) + # Handles repetition + if repeat and repeat not in new_task.tags: + new_task.repeats = repeat + new_task.tags.append(repeat) + storage.update_task(new_task, record_history=False) color = {'high': 'red', 'medium': 'yellow', 'low': 'green'}[priority] console.print(f"[green]✔[/green] Added task #{new_task.id}: [{color}]{task}[/{color}]") - + # Show notification if enabled if CONFIG.get('notifications', {}).get('on_creation', True): if all_tags: @@ -274,11 +289,8 @@ def ls(show_all): table.add_column("✔", width=3) table.add_column("Priority", width=8) table.add_column("Task") - if not compact_mode: - table.add_column("Tags", style=tag_color) - if show_dates: - table.add_column("Created", style="dim") - + table.add_column("Tags", style="dim") + table.add_column("Due", width=10) count = dict() for task in sorted(tasks, key=lambda t: (getattr(t, "completed", False), getattr(t, "id", 0))): @@ -288,6 +300,13 @@ def ls(show_all): tags_str = ", ".join(getattr(task, "tags", [])) if getattr(task, "tags", None) else "" attach_icon = " 📎" if getattr(task, "attachments", None) or getattr(task, "links", None) else "" + due_str = "" + if getattr(task, "due", None): + try: + due_date = date.fromisoformat(task.due) + due_str = due_date.strftime("%Y-%m-%d") + except ValueError: + due_str = task.due # text truncation text_val = getattr(task, "text", getattr(task, "task", "")) @@ -306,6 +325,7 @@ def ls(show_all): row.append(f"{text_val}{attach_icon}") if not compact_mode: row.append(tags_str) + row.append(due_str) if show_dates: created = getattr(task, "created", getattr(task, "created_at", None)) if created: @@ -360,6 +380,29 @@ def done(task_id): else: console.print(f"[green]✔[/green] Task #{task_id} completed") + if task.repeats and task.completed: + completed_date = date.fromisoformat(task.completed_at.split('T')[0]) + next_task_date = None + + if task.repeats == "daily": + next_task_date = completed_date + timedelta(days=1) + elif task.repeats == "weekly": + next_task_date = completed_date + timedelta(weeks=1) + elif task.repeats == "monthly": + next_task_date = completed_date + relativedelta(months=1) + + due_str = next_task_date.isoformat() if next_task_date else None + + new_task = storage.add_task( + task.text, + task.priority, + task.tags, + repeat=task.repeats, + due=due_str + ) + console.print(f"[bold green]➜[/bold green] New '{task.repeats}' task due on {due_str}") + + @cli.command() @click.argument("task_id", type=int) @@ -468,7 +511,8 @@ def clear(completed, force): @click.option('--remove-tag', multiple=True, help='Remove tags') @click.option('--attach', '-f', multiple=True, help='Attach file(s)') @click.option('--link', '-l', multiple=True, help='Attach URL(s)') -def edit(task_id, text, priority, add_tag, remove_tag, attach, link): +@click.option('--repeat', '-r', type=click.Choice(['daily', 'weekly', 'monthly']), help='New repeatability') +def edit(task_id, text, priority, add_tag, remove_tag, attach, link, repeat): """Edit a task""" task = storage.get_task(task_id) if not task: @@ -519,6 +563,12 @@ def edit(task_id, text, priority, add_tag, remove_tag, attach, link): task.links.extend(link) changes.append(f"links added: {list(link)}") + # Handle repeatability + if repeat: + old_repeat = task.repeats + task.repeats = repeat + changes.append(f"repeat: {old_repeat} → {repeat}") + if changes: storage.update_task(task) from tix.config import CONFIG @@ -673,7 +723,7 @@ def done_all(task_ids): if not_found: console.print(f"[red]Not found: {', '.join(map(str, not_found))}[/red]") - + @click.argument("name") def context(name): """Switch or create context""" @@ -1192,22 +1242,22 @@ def start(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) @@ -1226,19 +1276,19 @@ def stop(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: @@ -1260,7 +1310,7 @@ def status(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}") @@ -1273,7 +1323,7 @@ def status(task_id): 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() @@ -1284,15 +1334,15 @@ def status(task_id): @cli.command() -@click.option('--period', '-p', type=click.Choice(['week', 'month', 'all']), +@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" @@ -1302,24 +1352,24 @@ def timereport(period): 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 + 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") @@ -1327,14 +1377,14 @@ def timereport(period): 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() @@ -1354,9 +1404,9 @@ def timereport(period): 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, @@ -1365,9 +1415,9 @@ def timereport(period): 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)}") diff --git a/tix/models.py b/tix/models.py index 1ea9e1b..d1a57c9 100644 --- a/tix/models.py +++ b/tix/models.py @@ -14,12 +14,14 @@ class Task: tags: List[str] = field(default_factory=list) attachments: List[str] = field(default_factory=list) links: List[str] = field(default_factory=list) - + repeats: Optional[str] = None + # Time tracking fields estimate: Optional[int] = None time_spent: int = 0 started_at: Optional[str] = None time_logs: List[Dict] = field(default_factory=list) + due: Optional[str] = None def to_dict(self) -> dict: """Convert task to dictionary for JSON serialization""" @@ -33,10 +35,12 @@ def to_dict(self) -> dict: 'tags': self.tags, 'attachments': self.attachments, 'links': self.links, + 'repeats': self.repeats, 'estimate': self.estimate, 'time_spent': self.time_spent, 'started_at': self.started_at, - 'time_logs': self.time_logs + 'time_logs': self.time_logs, + "due": self.due } @classmethod @@ -52,10 +56,12 @@ def from_dict(cls, data: dict): tags=data.get('tags', []), attachments=data.get('attachments', []), links=data.get('links', []), + repeats=data.get('repeats'), estimate=data.get('estimate'), time_spent=data.get('time_spent', 0), started_at=data.get('started_at'), - time_logs=data.get('time_logs', []) + time_logs=data.get('time_logs', []), + due=data.get("due") ) def mark_done(self): @@ -78,20 +84,20 @@ 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: diff --git a/tix/storage/json_storage.py b/tix/storage/json_storage.py index 3e2e661..bf72982 100644 --- a/tix/storage/json_storage.py +++ b/tix/storage/json_storage.py @@ -1,8 +1,9 @@ import json +from datetime import datetime, date from pathlib import Path from typing import List, Optional from tix.models import Task -from tix.storage.history import HistoryManager +from tix.storage.history import HistoryManager class TaskStorage: @@ -11,7 +12,7 @@ class TaskStorage: def __init__(self, storage_path: Path = None, context: str = None, history: HistoryManager = None): """Initialize storage with default or custom path and context""" self.context = context or self._get_active_context() - + # Use context-specific storage path if storage_path: self.storage_path = storage_path @@ -21,7 +22,7 @@ def __init__(self, storage_path: Path = None, context: str = None, history: Hist self.storage_path = base_dir / "tasks.json" else: self.storage_path = base_dir / "contexts" / f"{self.context}.json" - + self.storage_path.parent.mkdir(parents=True, exist_ok=True) self._ensure_file() @@ -89,16 +90,18 @@ 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, estimate: int = 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, repeat: str = 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, + id=new_id, + text=text, + priority=priority, tags=tags or [], estimate=estimate, + repeats=repeat, + due=due.isoformat() if isinstance(due, (date, datetime)) else due ) data["tasks"].append(new_task.to_dict()) data["next_id"] = new_id + 1 @@ -160,7 +163,7 @@ def get_active_tasks(self) -> List[Task]: def get_completed_tasks(self) -> List[Task]: """Get all completed tasks""" return [t for t in self.load_tasks() if t.completed] - + def get_attachment_dir(self, task_id: int) -> Path: """Return the path where attachments for a task should be stored""" return Path.home() / ".tix" / "attachments" / str(task_id)