perf(ui): push task-list filtering into SQL

LoadForListAsync loaded the entire tasks table (all lists, two Include
joins) then filtered to the selected list in C#, never using
idx_tasks_list_id/idx_tasks_status. ITaskListFilter now exposes
MatchExpression, an Expression<Func<TaskEntity,bool>> that EF Core can
translate to SQL; a new TaskListFilterBase compiles it on demand for the
existing in-memory Matches(). The primary match query now runs as
db.Tasks.Where(filter.MatchExpression), with two small follow-up queries
(scoped by parent-id IN-lists) for contextual planning-parent rows and
pulled-in children, followed by an explicit re-sort to restore the global
SortOrder/CreatedAt ordering Regroup() depends on.
This commit is contained in:
mika kuns
2026-08-10 13:49:09 +02:00
parent 6a2a19cc9e
commit 7f10d45898
8 changed files with 152 additions and 35 deletions
@@ -1,13 +1,12 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ReviewFilter : ITaskListFilter
public sealed class ReviewFilter : TaskListFilterBase
{
public string Id => "virtual:review";
public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.WaitingForReview;
public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
public override string Id => "virtual:review";
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
public override bool ShouldCount(TaskEntity t) => Matches(t);
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,10 +8,11 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Filter for a smart list keyed off a boolean/nullable task flag
/// (My Day, Important, Planned). Counts only non-done matches.
/// </summary>
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
public sealed class SmartFlagFilter(string id, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
{
public string Id => id;
public bool Matches(TaskEntity t) => flag(t);
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,12 +8,12 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Virtual list filter matching tasks by a single status (Queued, Running).
/// Planning parents appear contextually when they host a matching child.
/// </summary>
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase
{
public string Id => id;
public bool Matches(TaskEntity t) => t.Status == status;
public bool ShouldCount(TaskEntity t) => t.Status == status;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
public override bool ShouldCount(TaskEntity t) => t.Status == status;
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}
@@ -0,0 +1,27 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
/// on first use, as the in-memory <see cref="Matches"/> predicate.
/// </summary>
public abstract class TaskListFilterBase : ITaskListFilter
{
private Func<TaskEntity, bool>? _compiled;
public abstract string Id { get; }
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
public abstract bool ShouldCount(TaskEntity t);
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,7 +8,7 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter : ITaskListFilter
public sealed class UserListFilter : TaskListFilterBase
{
private readonly string _listId;
@@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter
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;
public override string Id { get; }
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
@@ -15,6 +16,9 @@ public interface ITaskListFilter
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);