diff --git a/src/things_mcp/server.py b/src/things_mcp/server.py index d08e53c..e5d8963 100644 --- a/src/things_mcp/server.py +++ b/src/things_mcp/server.py @@ -300,7 +300,7 @@ def _stop_datetime(todo): @mcp.tool async def get_logbook(period: str = "7d", limit: int = 50, offset: int = 0) -> ToolResult: - """Get completed todos from Logbook, defaults to last 7 days + """Get completed and canceled todos from Logbook, defaults to last 7 days Args: period: Time period to look back (e.g., '3d', '1w', '2m', '1y'). Defaults to '7d' @@ -312,8 +312,9 @@ async def get_logbook(period: str = "7d", limit: int = 50, offset: int = 0) -> T return _error_result(err) # things.last(period, status='completed') filters on creationDate, not # stopDate — so tasks created before the window but completed inside it - # are invisible (which is most tasks in real use). Fetch all completed - # tasks and filter on stop_date in Python. + # are invisible (which is most tasks in real use). Fetch all logged + # tasks and filter on stop_date in Python. The Logbook holds both + # completed and canceled tasks, so query both statuses. delta = _parse_logbook_period(period) if delta is None: return _error_result( @@ -322,9 +323,11 @@ async def get_logbook(period: str = "7d", limit: int = 50, offset: int = 0) -> T ) cutoff = datetime.now() - delta - all_completed = things.tasks(status='completed', include_items=True) or [] + logged = [] + for status in ('completed', 'canceled'): + logged.extend(things.tasks(status=status, include_items=True) or []) in_window = [] - for todo in all_completed: + for todo in logged: stopped = _stop_datetime(todo) if stopped is not None and stopped >= cutoff: in_window.append((stopped, todo)) diff --git a/tests/test_things_server.py b/tests/test_things_server.py index 458b03d..12d0b30 100644 --- a/tests/test_things_server.py +++ b/tests/test_things_server.py @@ -171,6 +171,29 @@ def _completed(uuid, title, stop_date): } +def _canceled(uuid, title, stop_date): + return { + 'uuid': uuid, + 'title': title, + 'type': 'to-do', + 'status': 'canceled', + 'stop_date': stop_date, + } + + +def _mock_logbook_tasks(mocker, completed=(), canceled=()): + """Wire up things.tasks to answer per-status queries like the real API. + + get_logbook queries status='completed' and status='canceled' separately, + so a plain return_value would hand the same list to both queries. + """ + by_status = {'completed': list(completed), 'canceled': list(canceled)} + return mocker.patch( + 'things.tasks', + side_effect=lambda status=None, **kw: list(by_status.get(status, [])), + ) + + def test_parse_logbook_period_accepts_dwmy(): assert _parse_logbook_period('7d') == timedelta(days=7) assert _parse_logbook_period('2w') == timedelta(days=14) @@ -190,7 +213,7 @@ async def test_get_logbook_includes_tasks_completed_in_window_even_if_created_ea """Regression for #46: tasks created long ago but completed recently must appear.""" today = datetime.now().date().isoformat() long_ago_completed_recently = _completed('a', 'Old task done today', today) - mocker.patch('things.tasks', return_value=[long_ago_completed_recently]) + _mock_logbook_tasks(mocker, completed=[long_ago_completed_recently]) result = tool_text(await get_logbook(period='7d')) @@ -201,7 +224,7 @@ async def test_get_logbook_includes_tasks_completed_in_window_even_if_created_ea async def test_get_logbook_excludes_tasks_completed_before_window(mocker): long_ago = (datetime.now() - timedelta(days=60)).date().isoformat() today = datetime.now().date().isoformat() - mocker.patch('things.tasks', return_value=[ + _mock_logbook_tasks(mocker, completed=[ _completed('a', 'Within window', today), _completed('b', 'Outside window', long_ago), ]) @@ -217,7 +240,7 @@ async def test_get_logbook_sorts_newest_completion_first(mocker): today = datetime.now().date().isoformat() yesterday = (datetime.now() - timedelta(days=1)).date().isoformat() two_days_ago = (datetime.now() - timedelta(days=2)).date().isoformat() - mocker.patch('things.tasks', return_value=[ + _mock_logbook_tasks(mocker, completed=[ _completed('a', 'Two days ago', two_days_ago), _completed('b', 'Today', today), _completed('c', 'Yesterday', yesterday), @@ -231,7 +254,7 @@ async def test_get_logbook_sorts_newest_completion_first(mocker): @pytest.mark.asyncio async def test_get_logbook_respects_limit(mocker): today = datetime.now().date().isoformat() - mocker.patch('things.tasks', return_value=[ + _mock_logbook_tasks(mocker, completed=[ _completed(f'u{i}', f'Task {i}', today) for i in range(10) ]) @@ -249,6 +272,64 @@ async def test_get_logbook_invalid_period(mocker): assert 'Invalid period' in result +@pytest.mark.asyncio +async def test_get_logbook_queries_completed_and_canceled(mocker): + """The Logbook holds both statuses, so both must be fetched.""" + mock_tasks = _mock_logbook_tasks(mocker) + + await get_logbook(period='7d') + + statuses = [call.kwargs.get('status') for call in mock_tasks.call_args_list] + assert statuses == ['completed', 'canceled'] + assert all(call.kwargs.get('include_items') for call in mock_tasks.call_args_list) + + +@pytest.mark.asyncio +async def test_get_logbook_includes_canceled_items(mocker): + """Canceled items appear in the Things Logbook and must appear here too.""" + today = datetime.now().date().isoformat() + _mock_logbook_tasks(mocker, canceled=[_canceled('a', 'Dropped task', today)]) + + result = tool_text(await get_logbook(period='7d')) + + assert 'Dropped task' in result + assert 'Status: canceled' in result + + +@pytest.mark.asyncio +async def test_get_logbook_merges_statuses_sorted_by_stop_date(mocker): + today = datetime.now().date().isoformat() + yesterday = (datetime.now() - timedelta(days=1)).date().isoformat() + two_days_ago = (datetime.now() - timedelta(days=2)).date().isoformat() + _mock_logbook_tasks( + mocker, + completed=[ + _completed('a', 'Done two days ago', two_days_ago), + _completed('b', 'Done today', today), + ], + canceled=[_canceled('c', 'Dropped yesterday', yesterday)], + ) + + result = tool_text(await get_logbook(period='7d')) + + assert result.index('Done today') < result.index('Dropped yesterday') < result.index('Done two days ago') + + +@pytest.mark.asyncio +async def test_get_logbook_period_filter_applies_to_canceled_items(mocker): + long_ago = (datetime.now() - timedelta(days=60)).date().isoformat() + today = datetime.now().date().isoformat() + _mock_logbook_tasks(mocker, canceled=[ + _canceled('a', 'Dropped recently', today), + _canceled('b', 'Dropped long ago', long_ago), + ]) + + result = tool_text(await get_logbook(period='7d')) + + assert 'Dropped recently' in result + assert 'Dropped long ago' not in result + + # --- get_tag_usage (#14) ------------------------------------------------------ def _set_tag_data(mocker, *, tags, open_counts, all_counts): @@ -452,6 +533,10 @@ async def test_pagination_applies_after_someday_filtering(mocker): @pytest.mark.asyncio async def test_get_logbook_supports_offset(mocker): today = datetime.now().date().isoformat() - mocker.patch('things.tasks', return_value=[_completed(f'u{i}', f'L{i}', today) for i in range(5)]) + _mock_logbook_tasks( + mocker, + completed=[_completed(f'u{i}', f'L{i}', today) for i in range(4)], + canceled=[_canceled('u4', 'L4', today)], + ) result = tool_text(await get_logbook(period='7d', limit=2, offset=2)) assert 'Showing 3-4 of 5 items' in result