refactor: collapse single-implementation interfaces

Nine interfaces had exactly one implementation and no test double — they existed
only to be named twice in a DI registration: IFindingsStore, IFindingsStoreLocator,
IPrimeScheduleSignal, IRefineRunner, IWeekReportService, IMergeCoordinator,
IMissionControlPane, IOnlineLoginService, ITaskListFilter. Consumers now depend on
the concrete type; the DTO records that shared those files moved next to their
implementation. IInteractiveLaunchSpecService stays — it carries 54 lines of
contract documentation, which is not ceremony.

IMergeCoordinator in particular had a redundant null object: MergeCoordinator with
a null Handler already no-ops, and every test used the real class with Handler set.

Filtering/ collapses from 8 files to 1. ITaskListFilter and TaskListFilterBase were
a double abstraction over four predicates, with MatchesAsContext => false declared
in both. SmartFlagFilter also compiled its expression twice (its own _flag plus the
inherited Matches cache) — it now uses the cache.

StaticTokenAuthProvider was in src but production uses ZitadelAuthProvider; it is
a test double, so it moves to the test project. Its own test goes away with it.
This commit is contained in:
mika kuns
2026-08-26 10:12:23 +02:00
parent 76d836a278
commit 296251e27e
45 changed files with 174 additions and 327 deletions
@@ -1,12 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ReviewFilter : TaskListFilterBase
{
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,18 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// 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, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
{
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,19 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// 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) : TaskListFilterBase
{
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);
}
@@ -1,27 +0,0 @@
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,24 +0,0 @@
using System.Linq.Expressions;
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 : TaskListFilterBase
{
private readonly string _listId;
public UserListFilter(string listId)
{
_listId = listId;
Id = $"user:{listId}";
}
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,30 +0,0 @@
using System.Linq.Expressions;
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>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);
/// <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;
}
@@ -1,8 +1,84 @@
using ClaudeDo.Data.Filtering.Filters;
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Strategy that defines which tasks belong to a single list. One subclass per list kind;
/// consumers (counters, list loader) ask the registry for the right strategy and never branch
/// on the list id themselves.
///
/// Subclasses express their primary-match condition once as an expression tree
/// (<see cref="MatchExpression"/>), which doubles as a SQL-translatable predicate for EF Core
/// and, compiled on first use, as the in-memory <see cref="Matches"/> predicate.
/// </summary>
public abstract class TaskListFilter
{
private Func<TaskEntity, bool>? _compiled;
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
public abstract string Id { get; }
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
public abstract Expression<Func<TaskEntity, bool>> MatchExpression { get; }
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpression.Compile())(t);
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
public abstract 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>
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
/// <summary>
/// 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, Expression<Func<TaskEntity, bool>> flag) : TaskListFilter
{
public override string Id => id;
public override Expression<Func<TaskEntity, bool>> MatchExpression => flag;
public override bool ShouldCount(TaskEntity t) => Matches(t) && t.Status != TaskStatus.Done;
}
/// <summary>
/// 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) : TaskListFilter
{
public override string Id => id;
public override Expression<Func<TaskEntity, bool>> MatchExpression => 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);
}
public sealed class ReviewFilter : TaskListFilter
{
public override string Id => "virtual:review";
public override Expression<Func<TaskEntity, bool>> MatchExpression => t => t.Status == TaskStatus.WaitingForReview;
public override bool ShouldCount(TaskEntity t) => Matches(t);
}
/// <summary>
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter(string listId) : TaskListFilter
{
public override string Id { get; } = $"user:{listId}";
public override Expression<Func<TaskEntity, bool>> MatchExpression => t => t.ListId == listId;
public override bool ShouldCount(TaskEntity t) => t.ListId == listId && t.Status != TaskStatus.Done;
}
/// <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
@@ -12,8 +88,8 @@ public sealed class TaskListFilterRegistry
{
public const string UserListPrefix = "user:";
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
private static readonly IReadOnlyDictionary<string, TaskListFilter> BuiltIn =
new Dictionary<string, TaskListFilter>(StringComparer.Ordinal)
{
["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
@@ -26,7 +102,7 @@ public sealed class TaskListFilterRegistry
/// <summary>
/// Resolve a filter for a list id, or null if the id is unknown.
/// </summary>
public ITaskListFilter? Resolve(string listId)
public TaskListFilter? Resolve(string listId)
{
if (BuiltIn.TryGetValue(listId, out var f)) return f;
if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal))