Skip to content

Commit 12a407e

Browse files
committed
chore: Bump version to 0.10.1
## Improvements ### TUI Status Bar Enhancements - Add persistent sync/reload timing information - Show last sync time (e.g., "Synced 2m ago") - Show last reload time (e.g., "Reloaded 15s ago") - Display auto-sync interval and status - Show conflict warnings with count - Responsive layout for different terminal widths - New time_format.py utility for human-readable timestamps - Refactored status bar with modular helper methods ### Compact Overdue Format - Display overdue tasks as "-3d" instead of "overdue 3 days" - Display as "-2w" instead of "overdue 2 weeks" - Saves horizontal space in lists and TUI - Maintains red color for visibility ### Additional Improvements from Session - Fixed TUI delete confirmation (no Enter needed) - Fixed countdown display for completed tasks - Improved sorting logic to exclude completed/cancelled subtasks - Added merge conflict detection system - Implemented semantic merging (status/priority progression) - Removed unnecessary Enter prompts from TUI handlers
1 parent b9a1fcc commit 12a407e

24 files changed

Lines changed: 1868 additions & 672 deletions

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.10.1] - 2025-11-06
11+
12+
### Improved
13+
14+
- **TUI status bar enhancements**: Status bar now shows persistent sync and reload timing information
15+
- Always displays last sync time (e.g., "Synced 2m ago") when auto-sync is enabled
16+
- Shows last reload time (e.g., "Reloaded 15s ago") to verify file watching is working
17+
- Displays auto-sync interval and status (e.g., "Auto-sync: ON (5m)")
18+
- Shows conflict warnings with count when manual resolution needed
19+
- Responsive layout adapts to terminal width:
20+
- Two-line layout for terminals ≥120 columns (status info on line 1, shortcuts on line 2)
21+
- Single-line layout for narrower terminals with condensed shortcuts
22+
- Breakpoints at 80, 120, 160 columns for optimal shortcut visibility
23+
- Implementation:
24+
- New `src/taskrepo/utils/time_format.py` with human-readable time formatting
25+
- Refactored status bar generation into modular helper methods
26+
- Accurate height calculation accounting for HTML markup
27+
- State tracking for reload and sync timestamps
28+
29+
- **Compact overdue format**: Overdue tasks now use negative numbers for space efficiency
30+
- Displays as "-3d" instead of "overdue 3 days"
31+
- Displays as "-2w" instead of "overdue 2 weeks"
32+
- Saves significant horizontal space in task lists and TUI
33+
- Maintains red color for visibility
34+
- Affects both TUI and CLI list views
35+
1036
## [0.10.0] - 2025-11-05
1137

1238
### Added

src/taskrepo/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.10.0"
1+
__version__ = "0.10.1"

src/taskrepo/cli/commands/archive.py

Lines changed: 69 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Archive command for moving tasks to archive folder."""
22

3+
from typing import Tuple
4+
35
import click
46
from prompt_toolkit.shortcuts import prompt
57
from prompt_toolkit.validation import Validator
68

79
from taskrepo.core.repository import RepositoryManager
810
from taskrepo.tui.display import display_tasks_table
911
from taskrepo.utils.display_constants import STATUS_EMOJIS
10-
from taskrepo.utils.helpers import find_task_by_title_or_id, select_task_from_result
12+
from taskrepo.utils.helpers import process_tasks_batch
1113
from taskrepo.utils.id_mapping import get_cache_size
1214

1315

@@ -16,7 +18,7 @@
1618
@click.option("--repo", "-r", help="Repository name (will search all repos if not specified)")
1719
@click.option("--yes", "-y", is_flag=True, help="Automatically archive subtasks (skip prompt)")
1820
@click.pass_context
19-
def archive(ctx, task_ids, repo, yes):
21+
def archive(ctx, task_ids: Tuple[str, ...], repo, yes):
2022
"""Archive one or more tasks, or list archived tasks if no task IDs are provided.
2123
2224
TASK_IDS: One or more task IDs to archive (optional - if omitted, lists archived tasks)
@@ -32,14 +34,14 @@ def archive(ctx, task_ids, repo, yes):
3234
if not repository:
3335
click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True)
3436
ctx.exit(1)
35-
archived_tasks = repository.list_archived_tasks()
37+
archived_tasks_list = repository.list_archived_tasks()
3638
else:
3739
# Get archived tasks from all repos
38-
archived_tasks = []
40+
archived_tasks_list = []
3941
for r in manager.discover_repositories():
40-
archived_tasks.extend(r.list_archived_tasks())
42+
archived_tasks_list.extend(r.list_archived_tasks())
4143

42-
if not archived_tasks:
44+
if not archived_tasks_list:
4345
repo_msg = f" in repository '{repo}'" if repo else ""
4446
click.echo(f"No archived tasks found{repo_msg}.")
4547
return
@@ -49,126 +51,79 @@ def archive(ctx, task_ids, repo, yes):
4951

5052
# Display archived tasks with IDs starting after active tasks
5153
display_tasks_table(
52-
archived_tasks,
54+
archived_tasks_list,
5355
config,
54-
title=f"Archived Tasks ({len(archived_tasks)} found)",
56+
title=f"Archived Tasks ({len(archived_tasks_list)} found)",
5557
save_cache=False,
5658
id_offset=active_task_count,
5759
)
5860
return
5961

60-
# Process multiple task IDs
61-
archived_tasks = []
62-
failed_tasks = []
63-
repositories_to_update = set()
64-
65-
for task_id in task_ids:
66-
try:
67-
# Try to find task by ID or title
68-
result = find_task_by_title_or_id(manager, task_id, repo)
69-
70-
# Handle the result manually for batch processing
71-
if result[0] is None:
72-
# Not found
73-
if len(task_ids) > 1:
74-
click.secho(f"✗ No task found matching '{task_id}'", fg="red")
75-
failed_tasks.append(task_id)
76-
continue
77-
else:
78-
click.secho(f"Error: No task found matching '{task_id}'", fg="red", err=True)
79-
ctx.exit(1)
80-
81-
elif isinstance(result[0], list):
82-
# Multiple matches
83-
if len(task_ids) > 1:
84-
click.secho(f"✗ Multiple tasks found matching '{task_id}' - skipping", fg="red")
85-
failed_tasks.append(task_id)
86-
continue
87-
else:
88-
# Let select_task_from_result handle the interactive selection
89-
task, repository = select_task_from_result(ctx, result, task_id)
90-
else:
91-
# Single match found
92-
task, repository = result
93-
94-
# Check for subtasks and prompt (only for single task operations)
95-
if len(task_ids) == 1:
96-
subtasks_with_repos = manager.get_all_subtasks_cross_repo(task.id)
97-
98-
if subtasks_with_repos:
99-
count = len(subtasks_with_repos)
100-
subtask_word = "subtask" if count == 1 else "subtasks"
101-
102-
# Determine whether to archive subtasks
103-
archive_subtasks = yes # Default to --yes flag value
104-
105-
if not yes:
106-
# Show subtasks and prompt
107-
click.echo(f"\nThis task has {count} {subtask_word}:")
108-
for subtask, subtask_repo in subtasks_with_repos:
109-
status_emoji = STATUS_EMOJIS.get(subtask.status, "")
110-
click.echo(f" • {status_emoji} {subtask.title} (repo: {subtask_repo.name})")
111-
112-
# Prompt for confirmation with Y as default
113-
yn_validator = Validator.from_callable(
114-
lambda text: text.lower() in ["y", "n", "yes", "no"],
115-
error_message="Please enter 'y' or 'n'",
116-
move_cursor_to_end=True,
117-
)
118-
119-
response = prompt(
120-
f"Archive all {count} {subtask_word} too? (Y/n) ",
121-
default="y",
122-
validator=yn_validator,
123-
).lower()
124-
125-
archive_subtasks = response in ["y", "yes"]
126-
127-
if archive_subtasks:
128-
# Archive all subtasks
129-
archived_count = 0
130-
for subtask, subtask_repo in subtasks_with_repos:
131-
if subtask_repo.archive_task(subtask.id):
132-
archived_count += 1
133-
134-
if archived_count > 0:
135-
click.secho(f"✓ Archived {archived_count} {subtask_word}", fg="green")
136-
137-
# Archive the task
138-
success = repository.archive_task(task.id)
139-
140-
if success:
141-
archived_tasks.append((task, repository))
142-
repositories_to_update.add(repository)
143-
else:
144-
failed_tasks.append(task_id)
145-
if len(task_ids) > 1:
146-
click.secho(f"✗ Could not archive task '{task_id}'", fg="red")
147-
else:
148-
click.secho(f"Error: Could not archive task '{task_id}'", fg="red", err=True)
149-
ctx.exit(1)
150-
151-
except Exception as e:
152-
# Unexpected error - show message and continue with next task
153-
failed_tasks.append(task_id)
154-
if len(task_ids) > 1:
155-
click.secho(f"✗ Could not archive task '{task_id}': {e}", fg="red")
156-
else:
157-
raise
158-
159-
# Show summary
62+
def archive_task_handler(task, repository):
63+
"""Handler to archive a task."""
64+
success = repository.archive_task(task.id)
65+
if success:
66+
return True, None
67+
else:
68+
return False, f"Could not archive task '{task.id}'"
69+
70+
# Use batch processor
71+
archived_tasks, failed_tasks = process_tasks_batch(
72+
ctx, manager, task_ids, repo, task_handler=archive_task_handler, operation_name="archived"
73+
)
74+
75+
# Handle subtask prompting for single task
76+
if len(archived_tasks) == 1 and len(task_ids) == 1:
77+
task, _ = archived_tasks[0]
78+
subtasks_with_repos = manager.get_all_subtasks_cross_repo(task.id)
79+
80+
if subtasks_with_repos:
81+
count = len(subtasks_with_repos)
82+
subtask_word = "subtask" if count == 1 else "subtasks"
83+
84+
# Determine whether to archive subtasks
85+
archive_subtasks = yes # Default to --yes flag value
86+
87+
if not yes:
88+
# Show subtasks and prompt
89+
click.echo(f"\nThis task has {count} {subtask_word}:")
90+
for subtask, subtask_repo in subtasks_with_repos:
91+
status_emoji = STATUS_EMOJIS.get(subtask.status, "")
92+
click.echo(f" • {status_emoji} {subtask.title} (repo: {subtask_repo.name})")
93+
94+
# Prompt for confirmation with Y as default
95+
yn_validator = Validator.from_callable(
96+
lambda text: text.lower() in ["y", "n", "yes", "no"],
97+
error_message="Please enter 'y' or 'n'",
98+
move_cursor_to_end=True,
99+
)
100+
101+
response = prompt(
102+
f"Archive all {count} {subtask_word} too? (Y/n) ",
103+
default="y",
104+
validator=yn_validator,
105+
).lower()
106+
107+
archive_subtasks = response in ["y", "yes"]
108+
109+
if archive_subtasks:
110+
# Archive all subtasks
111+
archived_count = 0
112+
for subtask, subtask_repo in subtasks_with_repos:
113+
if subtask_repo.archive_task(subtask.id):
114+
archived_count += 1
115+
116+
if archived_count > 0:
117+
click.secho(f"✓ Archived {archived_count} {subtask_word}", fg="green")
118+
119+
# Show individual success messages
160120
if archived_tasks:
161121
click.echo()
162122
for task, _ in archived_tasks:
163123
click.secho(f"✓ Task archived: {task}", fg="green")
164124

165-
# Show summary for batch operations
166-
if len(task_ids) > 1:
167-
click.echo()
168-
click.secho(f"Archived {len(archived_tasks)} of {len(task_ids)} tasks", fg="green")
169-
170125
# Update cache and display archived tasks from all repos
171-
if repositories_to_update:
126+
if archived_tasks:
172127
from taskrepo.utils.id_mapping import save_id_cache
173128
from taskrepo.utils.sorting import sort_tasks
174129

Lines changed: 26 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Cancelled command for marking tasks as cancelled."""
22

3+
from typing import Tuple
4+
35
import click
46

57
from taskrepo.core.repository import RepositoryManager
68
from taskrepo.utils.helpers import (
7-
find_task_by_title_or_id,
9+
process_tasks_batch,
810
prompt_for_subtask_unarchiving,
9-
select_task_from_result,
1011
update_cache_and_display_repo,
1112
)
1213

@@ -15,89 +16,47 @@
1516
@click.argument("task_ids", nargs=-1, required=True)
1617
@click.option("--repo", "-r", help="Repository name (will search all repos if not specified)")
1718
@click.pass_context
18-
def cancelled(ctx, task_ids, repo):
19+
def cancelled(ctx, task_ids: Tuple[str, ...], repo):
1920
"""Mark one or more tasks as cancelled.
2021
2122
TASK_IDS: One or more task IDs to mark as cancelled (comma-separated)
2223
"""
2324
config = ctx.obj["config"]
2425
manager = RepositoryManager(config.parent_dir)
2526

26-
# Parse comma-separated task IDs
27-
task_id_list = []
28-
for task_id in task_ids:
29-
task_id_list.extend([tid.strip() for tid in task_id.split(",")])
30-
31-
# Process multiple task IDs
32-
updated_tasks = []
33-
failed_tasks = []
34-
repositories_to_update = set()
35-
36-
for task_id in task_id_list:
37-
try:
38-
# Try to find task by ID or title
39-
result = find_task_by_title_or_id(manager, task_id, repo)
40-
41-
# Handle the result manually for batch processing
42-
if result[0] is None:
43-
# Not found
44-
if len(task_id_list) > 1:
45-
click.secho(f"✗ No task found matching '{task_id}'", fg="red")
46-
failed_tasks.append(task_id)
47-
continue
48-
else:
49-
click.secho(f"Error: No task found matching '{task_id}'", fg="red", err=True)
50-
ctx.exit(1)
27+
# Track which tasks were completed (for subtask handling)
28+
was_completed_map = {}
5129

52-
elif isinstance(result[0], list):
53-
# Multiple matches
54-
if len(task_id_list) > 1:
55-
click.secho(f"✗ Multiple tasks found matching '{task_id}' - skipping", fg="red")
56-
failed_tasks.append(task_id)
57-
continue
58-
else:
59-
# Let select_task_from_result handle the interactive selection
60-
task, repository = select_task_from_result(ctx, result, task_id)
61-
else:
62-
# Single match found
63-
task, repository = result
30+
def mark_as_cancelled(task, repository):
31+
"""Handler to mark task as cancelled."""
32+
# Store completion status before changing
33+
was_completed_map[task.id] = task.status == "completed"
6434

65-
# Check if we're unarchiving a completed task (only for single task operations)
66-
was_completed = task.status == "completed"
35+
# Mark as cancelled
36+
task.status = "cancelled"
37+
repository.save_task(task)
38+
return True, None
6739

68-
# Mark as cancelled
69-
task.status = "cancelled"
70-
repository.save_task(task)
40+
# Use batch processor
41+
updated_tasks, failed_tasks = process_tasks_batch(
42+
ctx, manager, task_ids, repo, task_handler=mark_as_cancelled, operation_name="updated"
43+
)
7144

72-
# Prompt for subtasks if unarchiving and processing single task
73-
if was_completed and len(task_id_list) == 1:
74-
prompt_for_subtask_unarchiving(manager, task, "cancelled", batch_mode=False)
45+
# Handle subtask prompting for single task that was completed
46+
if len(updated_tasks) == 1 and len(task_ids) == 1:
47+
task, _ = updated_tasks[0]
48+
if was_completed_map.get(task.id, False):
49+
prompt_for_subtask_unarchiving(manager, task, "cancelled", batch_mode=False)
7550

76-
updated_tasks.append((task, repository))
77-
repositories_to_update.add(repository)
78-
79-
except Exception as e:
80-
# Unexpected error - show message and continue with next task
81-
failed_tasks.append(task_id)
82-
if len(task_id_list) > 1:
83-
click.secho(f"✗ Could not mark task '{task_id}' as cancelled: {e}", fg="red")
84-
else:
85-
raise
86-
87-
# Show summary
51+
# Show individual success messages
8852
if updated_tasks:
8953
click.echo()
9054
for task, _ in updated_tasks:
9155
click.secho(f"✓ Task marked as cancelled: {task}", fg="green")
9256

93-
# Show summary for batch operations
94-
if len(task_id_list) > 1:
95-
click.echo()
96-
click.secho(f"Updated {len(updated_tasks)} of {len(task_id_list)} tasks", fg="green")
97-
9857
# Update cache and display for affected repositories
99-
# For simplicity, just update the first repository or show all tasks
100-
if repositories_to_update:
58+
if updated_tasks:
59+
repositories_to_update = {repo for _, repo in updated_tasks}
10160
first_repo = list(repositories_to_update)[0]
10261
click.echo()
10362
update_cache_and_display_repo(manager, first_repo, config)

0 commit comments

Comments
 (0)