diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index eb6dbf9..ad80295 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -8,6 +8,7 @@
+
diff --git a/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs
new file mode 100644
index 0000000..6ec9c62
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs
@@ -0,0 +1,12 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+public sealed class ImportantFilter : ITaskListFilter
+{
+ public string Id => "smart:important";
+ public bool Matches(TaskEntity t) => t.IsStarred;
+ public bool ShouldCount(TaskEntity t) => t.IsStarred && t.Status != TaskStatus.Done;
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false;
+}
diff --git a/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs
new file mode 100644
index 0000000..63653fa
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs
@@ -0,0 +1,12 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+public sealed class MyDayFilter : ITaskListFilter
+{
+ public string Id => "smart:my-day";
+ public bool Matches(TaskEntity t) => t.IsMyDay;
+ public bool ShouldCount(TaskEntity t) => t.IsMyDay && t.Status != TaskStatus.Done;
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false;
+}
diff --git a/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs
new file mode 100644
index 0000000..e8cd839
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs
@@ -0,0 +1,12 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+public sealed class PlannedFilter : ITaskListFilter
+{
+ public string Id => "smart:planned";
+ public bool Matches(TaskEntity t) => t.ScheduledFor != null;
+ public bool ShouldCount(TaskEntity t) => t.ScheduledFor != null && t.Status != TaskStatus.Done;
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false;
+}
diff --git a/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs
new file mode 100644
index 0000000..1567601
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs
@@ -0,0 +1,14 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+public sealed class QueuedFilter : ITaskListFilter
+{
+ public string Id => "virtual:queued";
+ public bool Matches(TaskEntity t) => t.Status == TaskStatus.Queued;
+ public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Queued;
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) =>
+ PlanningRules.IsPlanningParent(t) &&
+ PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
+}
diff --git a/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
new file mode 100644
index 0000000..58a8f74
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
@@ -0,0 +1,14 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+public sealed class ReviewFilter : ITaskListFilter
+{
+ public string Id => "virtual:review";
+ public bool Matches(TaskEntity t) =>
+ t.Status == TaskStatus.Done &&
+ t.Worktree is { State: WorktreeState.Active };
+ public bool ShouldCount(TaskEntity t) => Matches(t);
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false;
+}
diff --git a/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs
new file mode 100644
index 0000000..56152ad
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs
@@ -0,0 +1,14 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+public sealed class RunningFilter : ITaskListFilter
+{
+ public string Id => "virtual:running";
+ public bool Matches(TaskEntity t) => t.Status == TaskStatus.Running;
+ public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Running;
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) =>
+ PlanningRules.IsPlanningParent(t) &&
+ PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
+}
diff --git a/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
new file mode 100644
index 0000000..3373dbf
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
@@ -0,0 +1,24 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Filtering.Filters;
+
+///
+/// Filter for any user-defined list. Constructed on demand from the list id —
+/// one instance per list.
+///
+public sealed class UserListFilter : ITaskListFilter
+{
+ private readonly string _listId;
+
+ public UserListFilter(string listId)
+ {
+ _listId = listId;
+ Id = $"user:{listId}";
+ }
+
+ public string Id { get; }
+ public bool Matches(TaskEntity t) => t.ListId == _listId;
+ public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
+ public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false;
+}
diff --git a/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs b/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs
new file mode 100644
index 0000000..79b1343
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs
@@ -0,0 +1,26 @@
+using ClaudeDo.Data.Models;
+
+namespace ClaudeDo.Data.Filtering;
+
+///
+/// Strategy that defines which tasks belong to a single list. One implementation
+/// per list kind; consumers (counters, list loader) ask the registry for the
+/// right strategy and never branch on the list id themselves.
+///
+public interface ITaskListFilter
+{
+ /// The list id this filter applies to (e.g. "virtual:queued", "user:abc").
+ string Id { get; }
+
+ /// True if is a primary citizen of this list — appears as a row.
+ bool Matches(TaskEntity t);
+
+ /// True if should be counted in this list's badge.
+ bool ShouldCount(TaskEntity t);
+
+ ///
+ /// True if is shown as a contextual row (not a primary citizen,
+ /// but appears to host children that match). Default: nothing extra.
+ ///
+ bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false;
+}
diff --git a/src/ClaudeDo.Data/Filtering/PlanningRules.cs b/src/ClaudeDo.Data/Filtering/PlanningRules.cs
new file mode 100644
index 0000000..fd90bf7
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/PlanningRules.cs
@@ -0,0 +1,27 @@
+using ClaudeDo.Data.Models;
+
+namespace ClaudeDo.Data.Filtering;
+
+///
+/// Shared predicates that capture planning-hierarchy semantics. Any new rule
+/// involving parents, children, or planning phases belongs here.
+///
+public static class PlanningRules
+{
+ public static bool IsPlanningParent(TaskEntity t) =>
+ t.PlanningPhase != PlanningPhase.None;
+
+ public static bool HasMatchingChild(
+ TaskEntity parent,
+ IReadOnlyList all,
+ Func childPredicate)
+ {
+ for (var i = 0; i < all.Count; i++)
+ {
+ var c = all[i];
+ if (c.ParentTaskId == parent.Id && childPredicate(c))
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs b/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
new file mode 100644
index 0000000..a5cfeba
--- /dev/null
+++ b/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
@@ -0,0 +1,38 @@
+using ClaudeDo.Data.Filtering.Filters;
+
+namespace ClaudeDo.Data.Filtering;
+
+///
+/// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that
+/// owns its semantics. Smart and virtual filters are singletons; user-list
+/// filters are constructed on demand from the id.
+///
+public sealed class TaskListFilterRegistry
+{
+ public const string UserListPrefix = "user:";
+
+ private static readonly IReadOnlyDictionary BuiltIn =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["smart:my-day"] = new MyDayFilter(),
+ ["smart:important"] = new ImportantFilter(),
+ ["smart:planned"] = new PlannedFilter(),
+ ["virtual:queued"] = new QueuedFilter(),
+ ["virtual:running"] = new RunningFilter(),
+ ["virtual:review"] = new ReviewFilter(),
+ };
+
+ ///
+ /// Resolve a filter for a list id, or null if the id is unknown.
+ ///
+ public ITaskListFilter? Resolve(string listId)
+ {
+ if (BuiltIn.TryGetValue(listId, out var f)) return f;
+ if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal))
+ {
+ var inner = listId[UserListPrefix.Length..];
+ return string.IsNullOrEmpty(inner) ? null : new UserListFilter(inner);
+ }
+ return null;
+ }
+}
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
index a47e1e3..55b22fc 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
+using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories;
@@ -19,6 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
private readonly IDbContextFactory _dbFactory;
private readonly IServiceProvider? _services;
private readonly WorkerClient? _worker;
+ private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged;
public event EventHandler? FocusSearchRequested;
@@ -129,38 +131,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
- // Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
- var open = await ctx.Tasks.AsNoTracking()
- .Where(t => t.Status != TaskStatus.Done)
- .Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
+ // Single snapshot; counters and the list loader share the same filter strategies.
+ var all = await ctx.Tasks.AsNoTracking()
+ .Include(t => t.Worktree)
.ToListAsync(ct);
- var running = open.Count(t => t.Status == TaskStatus.Running);
- var queued = open.Count(t => t.Status == TaskStatus.Queued);
- var review = await ctx.Tasks.AsNoTracking()
- .Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
- .CountAsync(ct);
-
foreach (var item in SmartLists)
{
- item.Count = item.Id switch
- {
- "smart:my-day" => open.Count(t => t.IsMyDay),
- "smart:important" => open.Count(t => t.IsStarred),
- "smart:planned" => open.Count(t => t.Scheduled != null),
- "virtual:queued" => queued,
- "virtual:running" => running,
- "virtual:review" => review,
- _ => 0,
- };
+ var filter = _filters.Resolve(item.Id);
+ item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
foreach (var item in UserLists)
{
- var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
- ? item.Id["user:".Length..]
- : item.Id;
- item.Count = open.Count(t => t.ListId == listId);
+ var filter = _filters.Resolve(item.Id);
+ item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
}
catch (OperationCanceledException) { throw; }
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
index 8cf6c91..f0187d8 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
@@ -3,6 +3,7 @@ using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
+using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -18,6 +19,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private readonly Dictionary _expandedState = new();
private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts;
+ private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested;
@@ -192,25 +194,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
ct.ThrowIfCancellationRequested();
- static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None;
-
- IEnumerable filtered = list.Kind switch
- {
- ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
- ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
- ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
- ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
- (t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
- (IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))),
- ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
- (t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
- (IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
- ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null),
- ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
- _ => Enumerable.Empty(),
- };
-
- var filteredList = filtered.ToList();
+ var filter = _filters.Resolve(list.Id);
+ var filteredList = filter is null
+ ? new List()
+ : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
@@ -282,17 +269,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
// Items is already ordered by SortOrder from the DB query.
- var topLevel = Items.Where(r => !r.IsChild);
+ // Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
+ var visibleIds = Items.Select(r => r.Id).ToHashSet();
+ bool IsTopLevel(TaskRowViewModel r) =>
+ !r.IsChild
+ || string.IsNullOrEmpty(r.ParentTaskId)
+ || !visibleIds.Contains(r.ParentTaskId!);
+ var topLevel = Items.Where(IsTopLevel);
var flat = new List();
+ var emitted = new HashSet();
foreach (var parent in topLevel)
{
+ if (!emitted.Add(parent.Id)) continue;
flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification
// loop and land in CompletedItems alongside the parent.
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{
var children = Items.Where(r => r.ParentTaskId == parent.Id);
- flat.AddRange(children);
+ foreach (var c in children)
+ if (emitted.Add(c.Id))
+ flat.Add(c);
}
}
diff --git a/tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj b/tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj
new file mode 100644
index 0000000..39a77c0
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs
new file mode 100644
index 0000000..a8ab776
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs
@@ -0,0 +1,49 @@
+using ClaudeDo.Data.Filtering;
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Tests.Filtering;
+
+public sealed class PlanningRulesTests
+{
+ [Theory]
+ [InlineData(PlanningPhase.None, false)]
+ [InlineData(PlanningPhase.Active, true)]
+ [InlineData(PlanningPhase.Finalized, true)]
+ public void IsPlanningParent_reflects_phase(PlanningPhase phase, bool expected)
+ {
+ var t = TaskFactory.Make("p", phase: phase);
+ Assert.Equal(expected, PlanningRules.IsPlanningParent(t));
+ }
+
+ [Fact]
+ public void HasMatchingChild_true_when_a_child_matches()
+ {
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
+ var child = TaskFactory.Make("c", parentId: "p", status: TaskStatus.Queued);
+ var all = new List { parent, child };
+
+ Assert.True(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
+ }
+
+ [Fact]
+ public void HasMatchingChild_false_when_no_child_matches()
+ {
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
+ var child = TaskFactory.Make("c", parentId: "p", status: TaskStatus.Done);
+ var all = new List { parent, child };
+
+ Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
+ }
+
+ [Fact]
+ public void HasMatchingChild_ignores_other_parents_children()
+ {
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
+ var otherParent = TaskFactory.Make("p2", phase: PlanningPhase.Active);
+ var foreignKid = TaskFactory.Make("c", parentId: "p2", status: TaskStatus.Queued);
+ var all = new List { parent, otherParent, foreignKid };
+
+ Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
+ }
+}
diff --git a/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs
new file mode 100644
index 0000000..a80a49e
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs
@@ -0,0 +1,46 @@
+using ClaudeDo.Data.Filtering.Filters;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Tests.Filtering;
+
+public sealed class SmartFilterTests
+{
+ [Fact]
+ public void MyDay_matches_my_day_tasks_regardless_of_status()
+ {
+ var f = new MyDayFilter();
+ Assert.True (f.Matches(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Idle)));
+ Assert.True (f.Matches(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done)));
+ Assert.False(f.Matches(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle)));
+ }
+
+ [Fact]
+ public void MyDay_count_excludes_done()
+ {
+ var f = new MyDayFilter();
+ Assert.True (f.ShouldCount(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Queued)));
+ Assert.False(f.ShouldCount(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done)));
+ Assert.False(f.ShouldCount(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle)));
+ }
+
+ [Fact]
+ public void Important_uses_IsStarred_with_same_split()
+ {
+ var f = new ImportantFilter();
+ Assert.True (f.Matches (TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done)));
+ Assert.False(f.ShouldCount(TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done)));
+ Assert.True (f.ShouldCount(TaskFactory.Make("b", isStarred: true, status: TaskStatus.Queued)));
+ Assert.False(f.Matches (TaskFactory.Make("c", isStarred: false)));
+ }
+
+ [Fact]
+ public void Planned_uses_ScheduledFor_with_same_split()
+ {
+ var f = new PlannedFilter();
+ var when = DateTime.Today;
+ Assert.True (f.Matches (TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done)));
+ Assert.False(f.ShouldCount(TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done)));
+ Assert.True (f.ShouldCount(TaskFactory.Make("b", scheduled: when, status: TaskStatus.Idle)));
+ Assert.False(f.Matches (TaskFactory.Make("c", scheduled: null)));
+ }
+}
diff --git a/tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs b/tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs
new file mode 100644
index 0000000..1b89b17
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs
@@ -0,0 +1,46 @@
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Tests.Filtering;
+
+internal static class TaskFactory
+{
+ public static TaskEntity Make(
+ string id,
+ string listId = "L",
+ TaskStatus status = TaskStatus.Idle,
+ PlanningPhase phase = PlanningPhase.None,
+ string? parentId = null,
+ bool isMyDay = false,
+ bool isStarred = false,
+ DateTime? scheduled = null,
+ WorktreeState? worktreeState = null)
+ {
+ var t = new TaskEntity
+ {
+ Id = id,
+ ListId = listId,
+ Title = id,
+ CreatedAt = DateTime.UtcNow,
+ Status = status,
+ PlanningPhase = phase,
+ ParentTaskId = parentId,
+ IsMyDay = isMyDay,
+ IsStarred = isStarred,
+ ScheduledFor = scheduled,
+ };
+ if (worktreeState is { } s)
+ {
+ t.Worktree = new WorktreeEntity
+ {
+ TaskId = id,
+ Path = "/tmp/" + id,
+ BranchName = "br/" + id,
+ BaseCommit = "deadbeef",
+ CreatedAt = DateTime.UtcNow,
+ State = s,
+ };
+ }
+ return t;
+ }
+}
diff --git a/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs
new file mode 100644
index 0000000..8cbe145
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs
@@ -0,0 +1,39 @@
+using ClaudeDo.Data.Filtering;
+using ClaudeDo.Data.Filtering.Filters;
+
+namespace ClaudeDo.Data.Tests.Filtering;
+
+public sealed class TaskListFilterRegistryTests
+{
+ private readonly TaskListFilterRegistry _registry = new();
+
+ [Theory]
+ [InlineData("smart:my-day", typeof(MyDayFilter))]
+ [InlineData("smart:important", typeof(ImportantFilter))]
+ [InlineData("smart:planned", typeof(PlannedFilter))]
+ [InlineData("virtual:queued", typeof(QueuedFilter))]
+ [InlineData("virtual:running", typeof(RunningFilter))]
+ [InlineData("virtual:review", typeof(ReviewFilter))]
+ public void Resolves_known_built_in_filters(string id, Type expected)
+ {
+ var f = _registry.Resolve(id);
+ Assert.NotNull(f);
+ Assert.IsType(expected, f);
+ Assert.Equal(id, f!.Id);
+ }
+
+ [Fact]
+ public void Resolves_user_list_filter_from_prefixed_id()
+ {
+ var f = _registry.Resolve("user:abc123");
+ var user = Assert.IsType(f);
+ Assert.Equal("user:abc123", user.Id);
+ }
+
+ [Fact]
+ public void Returns_null_for_unknown_or_empty_user_id()
+ {
+ Assert.Null(_registry.Resolve("bogus"));
+ Assert.Null(_registry.Resolve("user:"));
+ }
+}
diff --git a/tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs
new file mode 100644
index 0000000..3d72d71
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs
@@ -0,0 +1,31 @@
+using ClaudeDo.Data.Filtering.Filters;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Tests.Filtering;
+
+public sealed class UserListFilterTests
+{
+ [Fact]
+ public void Id_uses_user_prefix()
+ {
+ var f = new UserListFilter("inbox");
+ Assert.Equal("user:inbox", f.Id);
+ }
+
+ [Fact]
+ public void Matches_only_tasks_in_the_owning_list()
+ {
+ var f = new UserListFilter("inbox");
+ Assert.True (f.Matches(TaskFactory.Make("a", listId: "inbox")));
+ Assert.False(f.Matches(TaskFactory.Make("b", listId: "other")));
+ }
+
+ [Fact]
+ public void Count_excludes_done_tasks()
+ {
+ var f = new UserListFilter("inbox");
+ Assert.True (f.ShouldCount(TaskFactory.Make("a", listId: "inbox", status: TaskStatus.Idle)));
+ Assert.False(f.ShouldCount(TaskFactory.Make("b", listId: "inbox", status: TaskStatus.Done)));
+ Assert.False(f.ShouldCount(TaskFactory.Make("c", listId: "other", status: TaskStatus.Idle)));
+ }
+}
diff --git a/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs
new file mode 100644
index 0000000..8494f97
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs
@@ -0,0 +1,97 @@
+using ClaudeDo.Data.Filtering.Filters;
+using ClaudeDo.Data.Models;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Data.Tests.Filtering;
+
+public sealed class VirtualFilterTests
+{
+ // --- Queued ---
+
+ [Fact]
+ public void Queued_matches_every_queued_task_regardless_of_parent()
+ {
+ var f = new QueuedFilter();
+ Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Queued)));
+ Assert.True (f.Matches(TaskFactory.Make("b", status: TaskStatus.Queued, parentId: "p")));
+ Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Running)));
+ }
+
+ [Fact]
+ public void Queued_count_equals_match_for_top_level_and_children_alike()
+ {
+ // The point of the consolidation: counter must agree with display set.
+ var f = new QueuedFilter();
+ var orphan = TaskFactory.Make("o", status: TaskStatus.Queued, parentId: "missing");
+ Assert.True(f.Matches(orphan));
+ Assert.True(f.ShouldCount(orphan));
+ }
+
+ [Fact]
+ public void Queued_planning_parent_with_queued_kid_is_context_match()
+ {
+ var f = new QueuedFilter();
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.Active, status: TaskStatus.Done);
+ var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued);
+ var all = new List { parent, kid };
+
+ Assert.False(f.Matches(parent));
+ Assert.True (f.MatchesAsContext(parent, all));
+ }
+
+ [Fact]
+ public void Queued_non_planning_parent_is_never_context_match()
+ {
+ var f = new QueuedFilter();
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.None, status: TaskStatus.Done);
+ var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued);
+ var all = new List { parent, kid };
+
+ Assert.False(f.MatchesAsContext(parent, all));
+ }
+
+ [Fact]
+ public void Queued_planning_parent_without_queued_kid_is_not_context_match()
+ {
+ var f = new QueuedFilter();
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
+ var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Done);
+ var all = new List { parent, kid };
+
+ Assert.False(f.MatchesAsContext(parent, all));
+ }
+
+ // --- Running mirrors Queued ---
+
+ [Fact]
+ public void Running_matches_and_context_mirror_queued()
+ {
+ var f = new RunningFilter();
+ var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
+ var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Running);
+ var all = new List { parent, kid };
+
+ Assert.True (f.Matches(kid));
+ Assert.True (f.MatchesAsContext(parent, all));
+ }
+
+ // --- Review ---
+
+ [Fact]
+ public void Review_matches_only_done_with_active_worktree()
+ {
+ var f = new ReviewFilter();
+ Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active)));
+ Assert.False(f.Matches(TaskFactory.Make("b", status: TaskStatus.Done, worktreeState: WorktreeState.Merged)));
+ Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Done, worktreeState: null)));
+ Assert.False(f.Matches(TaskFactory.Make("d", status: TaskStatus.Failed, worktreeState: WorktreeState.Active)));
+ }
+
+ [Fact]
+ public void Review_count_equals_match()
+ {
+ var f = new ReviewFilter();
+ var t = TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active);
+ Assert.Equal(f.Matches(t), f.ShouldCount(t));
+ }
+}