refactor(filtering): consolidate task list filters into single strategy registry

Replace the three drifting filter implementations (counter, list loader,
regroup) with one ITaskListFilter strategy per list kind. Counter and list
loader now share the same predicates, so they cannot diverge again. Planning
hierarchy rules (parent-as-context, orphan handling) live in PlanningRules
and are unit-tested via 29 new tests in ClaudeDo.Data.Tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-18 15:18:33 +02:00
parent a6608bf8b3
commit e68bb737e3
20 changed files with 556 additions and 45 deletions

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
}

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
}

View File

@@ -0,0 +1,24 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
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<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,26 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// 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.
/// </summary>
public interface ITaskListFilter
{
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
string Id { get; }
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);
/// <summary>
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
/// but appears to host children that match). Default: nothing extra.
/// </summary>
bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,27 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Shared predicates that capture planning-hierarchy semantics. Any new rule
/// involving parents, children, or planning phases belongs here.
/// </summary>
public static class PlanningRules
{
public static bool IsPlanningParent(TaskEntity t) =>
t.PlanningPhase != PlanningPhase.None;
public static bool HasMatchingChild(
TaskEntity parent,
IReadOnlyList<TaskEntity> all,
Func<TaskEntity, bool> childPredicate)
{
for (var i = 0; i < all.Count; i++)
{
var c = all[i];
if (c.ParentTaskId == parent.Id && childPredicate(c))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,38 @@
using ClaudeDo.Data.Filtering.Filters;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// 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.
/// </summary>
public sealed class TaskListFilterRegistry
{
public const string UserListPrefix = "user:";
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(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(),
};
/// <summary>
/// Resolve a filter for a list id, or null if the id is unknown.
/// </summary>
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;
}
}

View File

@@ -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<ClaudeDoDbContext> _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; }

View File

@@ -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<string, bool> _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<TaskEntity> 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<TaskEntity>(),
};
var filteredList = filtered.ToList();
var filter = _filters.Resolve(list.Id);
var filteredList = filter is null
? new List<TaskEntity>()
: 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<TaskRowViewModel>();
var emitted = new HashSet<string>();
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);
}
}