Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/things_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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(
Expand All @@ -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))
Expand Down
95 changes: 90 additions & 5 deletions tests/test_things_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'))

Expand All @@ -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),
])
Expand All @@ -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),
Expand All @@ -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)
])

Expand All @@ -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):
Expand Down Expand Up @@ -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