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:
12
src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs
Normal file
12
src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs
Normal 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;
|
||||
}
|
||||
12
src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs
Normal file
12
src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs
Normal 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;
|
||||
}
|
||||
12
src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs
Normal file
12
src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs
Normal 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;
|
||||
}
|
||||
14
src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs
Normal 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);
|
||||
}
|
||||
14
src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
Normal 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;
|
||||
}
|
||||
14
src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs
Normal 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);
|
||||
}
|
||||
24
src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
Normal file
24
src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
Normal 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;
|
||||
}
|
||||
26
src/ClaudeDo.Data/Filtering/ITaskListFilter.cs
Normal file
26
src/ClaudeDo.Data/Filtering/ITaskListFilter.cs
Normal 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;
|
||||
}
|
||||
27
src/ClaudeDo.Data/Filtering/PlanningRules.cs
Normal file
27
src/ClaudeDo.Data/Filtering/PlanningRules.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
Normal file
38
src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user