diff --git a/packages/taskdog-core/src/taskdog_core/domain/repositories/task_repository.py b/packages/taskdog-core/src/taskdog_core/domain/repositories/task_repository.py index dccd7c52..73dfdb6c 100644 --- a/packages/taskdog-core/src/taskdog_core/domain/repositories/task_repository.py +++ b/packages/taskdog-core/src/taskdog_core/domain/repositories/task_repository.py @@ -101,8 +101,11 @@ def _matches_date_filter( ) -> bool: """Check whether any of the task's date fields fall within the range. - Mirrors the SQL date filtering logic: deadline, planned_start, - planned_end, actual_start and actual_end are combined with OR logic. + Mirrors the SQL date filtering logic in + ``TaskQueryBuilder._build_date_filter_conditions``: deadline, + planned_start, planned_end, actual_start and actual_end are combined + with OR logic, and ``end_date`` is whole-day inclusive so a value that + carries a time on the boundary day still matches. Args: task: Task whose date fields are inspected diff --git a/packages/taskdog-core/src/taskdog_core/infrastructure/persistence/database/query_builders/task_query_builder.py b/packages/taskdog-core/src/taskdog_core/infrastructure/persistence/database/query_builders/task_query_builder.py index a0881ffa..76a797e8 100644 --- a/packages/taskdog-core/src/taskdog_core/infrastructure/persistence/database/query_builders/task_query_builder.py +++ b/packages/taskdog-core/src/taskdog_core/infrastructure/persistence/database/query_builders/task_query_builder.py @@ -8,7 +8,7 @@ (archived, status, tags, dates) are translated to SQL for optimal performance. """ -from datetime import date +from datetime import date, timedelta from sqlalchemy import func, or_, select from sqlalchemy.sql.expression import ColumnElement @@ -188,9 +188,14 @@ def _build_date_filter_conditions( This helper method creates SQLAlchemy filter conditions for date range filtering across all date fields (deadline, planned_start, planned_end, actual_start, actual_end). It handles three cases for each field: - - Both start and end dates: field.between(start_date, end_date) + - Both start and end dates: field >= start_date AND field < end_date + 1 day - Only start date: field >= start_date - - Only end date: field <= end_date + - Only end date: field < end_date + 1 day + + The columns store datetimes, so ``end_date`` is turned into an exclusive + next-day bound. Comparing against the bare date would coerce it to + midnight and drop same-day values that carry a time, which disagrees + with ``TaskRepository._matches_date_filter``. Args: start_date: Minimum date for filtering (inclusive), or None @@ -216,11 +221,14 @@ def _build_date_filter_conditions( # Build conditions for each date field for field in date_fields: + end_bound = end_date + timedelta(days=1) if end_date else None if start_date and end_date: - date_conditions.append(field.between(start_date, end_date)) # type: ignore[attr-defined] + date_conditions.append( + (field >= start_date) & (field < end_bound) # type: ignore[operator] + ) elif start_date: date_conditions.append(field >= start_date) # type: ignore[arg-type,operator] elif end_date: - date_conditions.append(field <= end_date) # type: ignore[arg-type,operator] + date_conditions.append(field < end_bound) # type: ignore[arg-type,operator] return date_conditions diff --git a/packages/taskdog-core/tests/infrastructure/persistence/database/query_builders/test_task_query_builder.py b/packages/taskdog-core/tests/infrastructure/persistence/database/query_builders/test_task_query_builder.py index 8758d97e..3631ea0e 100644 --- a/packages/taskdog-core/tests/infrastructure/persistence/database/query_builders/test_task_query_builder.py +++ b/packages/taskdog-core/tests/infrastructure/persistence/database/query_builders/test_task_query_builder.py @@ -172,7 +172,7 @@ def test_with_date_filter_end_date_only(self): assert "deadline" in result_str def test_with_date_filter_both_dates(self): - """Test that with_date_filter adds BETWEEN clause for date range.""" + """Test that with_date_filter adds a half-open range for a date range.""" base_stmt = select(TaskModel) builder = TaskQueryBuilder(base_stmt) @@ -180,10 +180,11 @@ def test_with_date_filter_both_dates(self): start_date=date(2025, 1, 1), end_date=date(2025, 12, 31) ).build() - # Should add WHERE clause with BETWEEN for date range + # Should add WHERE clause with an inclusive lower and exclusive upper bound result_str = str(result).lower() assert "where" in result_str - assert "between" in result_str + assert "deadline >=" in result_str + assert "deadline <" in result_str assert "deadline" in result_str def test_method_chaining_fluent_interface(self): diff --git a/packages/taskdog-core/tests/infrastructure/persistence/database/test_sqlite_task_repository.py b/packages/taskdog-core/tests/infrastructure/persistence/database/test_sqlite_task_repository.py index 0cef2997..e672b618 100644 --- a/packages/taskdog-core/tests/infrastructure/persistence/database/test_sqlite_task_repository.py +++ b/packages/taskdog-core/tests/infrastructure/persistence/database/test_sqlite_task_repository.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from fixtures.repositories import InMemoryTaskRepository from taskdog_core.domain.constants import MAX_TAGS_PER_TASK from taskdog_core.domain.entities.task import Task, TaskStatus @@ -537,6 +538,38 @@ def test_count_tasks_with_date_filter(self): before_feb_count = self.repository.count_tasks(end_date=date(2025, 2, 28)) assert before_feb_count == 3 # task1, task2, task4 + def test_date_filter_end_date_includes_same_day_time(self): + """Test end_date is whole-day inclusive for time-bearing values. + + The columns store datetimes, so a bare ``end_date`` bound is coerced to + midnight and used to drop same-day values that carry a time. That + disagrees with the default ``TaskRepository.get_filtered()``, which + compares dates only. + """ + task = Task( + id=1, name="Boundary", priority=1, deadline=datetime(2025, 2, 28, 10, 0) + ) + self.repository.save(task) + + end_date = date(2025, 2, 28) + in_memory = InMemoryTaskRepository() + in_memory.save(task) + + assert len(self.repository.get_filtered(end_date=end_date)) == 1 + assert ( + len( + self.repository.get_filtered( + start_date=date(2025, 2, 1), end_date=end_date + ) + ) + == 1 + ) + assert self.repository.count_tasks(end_date=end_date) == 1 + # Both repository implementations must agree on the boundary. + assert len(in_memory.get_filtered(end_date=end_date)) == 1 + # The day after the deadline is still excluded. + assert len(self.repository.get_filtered(end_date=date(2025, 2, 27))) == 0 + def test_count_tasks_with_combined_filters(self): """Test count_tasks() with multiple filters combined.""" # Create diverse test data