diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index af83223..db9fb90 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -157,19 +157,34 @@ public sealed partial class TasksIslandViewModel : ViewModelBase ct.ThrowIfCancellationRequested(); + static bool IsPlanningStatus(TaskStatus s) => s == TaskStatus.Planning || s == TaskStatus.Planned; + 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), - ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running), + ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => + (t.Status == TaskStatus.Queued && t.ParentTaskId == null) || + (IsPlanningStatus(t.Status) && 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) || + (IsPlanningStatus(t.Status) && 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), ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id), _ => Enumerable.Empty(), }; - foreach (var t in filtered) + var filteredList = filtered.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!))) + { + if (existingIds.Add(c.Id)) + filteredList.Add(c); + } + + foreach (var t in filteredList) Items.Add(TaskRowViewModel.FromEntity(t)); Regroup(); @@ -198,7 +213,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase foreach (var parent in topLevel) { flat.Add(parent); - if (parent.IsPlanningParent && parent.IsExpanded) + if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded) { var children = Items.Where(r => r.ParentTaskId == parent.Id); flat.AddRange(children); @@ -208,7 +223,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var today = DateTime.Today; foreach (var r in flat) { - if (r.Done) + var underOpenPlanningParent = r.IsChild && + flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done); + + if (r.Done && !underOpenPlanningParent) CompletedItems.Add(r); else if (r.ScheduledFor is { } d && d.Date < today) OverdueItems.Add(r); diff --git a/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj b/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj index e8fd759..e561641 100644 --- a/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj +++ b/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj @@ -12,11 +12,13 @@ + + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs new file mode 100644 index 0000000..08ec6bf --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs @@ -0,0 +1,174 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class TasksIslandRegroupTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandRegroupTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_test_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private TasksIslandViewModel BuildViewModel() + { + var factory = new TestDbFactory(NewContext); + return new TasksIslandViewModel(factory, worker: null); + } + + private async Task SeedPlanningWithChildAsync( + TaskStatus parentStatus, + TaskStatus childStatus, + string parentId = "p1", + string childId = "c1") + { + await using var db = NewContext(); + var list = new ListEntity + { + Id = "list1", + Name = "Default", + CreatedAt = DateTime.UtcNow, + }; + db.Lists.Add(list); + + db.Tasks.Add(new TaskEntity + { + Id = parentId, + ListId = list.Id, + Title = "Parent", + CreatedAt = DateTime.UtcNow, + Status = parentStatus, + SortOrder = 0, + }); + db.Tasks.Add(new TaskEntity + { + Id = childId, + ListId = list.Id, + Title = "Child", + CreatedAt = DateTime.UtcNow, + Status = childStatus, + ParentTaskId = parentId, + SortOrder = 1, + }); + await db.SaveChangesAsync(); + } + + private static ListNavItemViewModel VirtualList(string id, string name) => + new() { Id = id, Kind = ListKind.Virtual, Name = name }; + + private static ListNavItemViewModel UserList(string listEntityId, string name) => + new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; + + private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) + { + vm.LoadForList(list); + + // LoadForList fires a background Task; wait briefly until Items are populated + // or until a timeout occurs (some tests may legitimately expect 0 items, so + // we just wait a short deterministic period). + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(25); + // Break out as soon as any Items present, or the background task has settled. + if (vm.Items.Count > 0) break; + } + // One more tick for Regroup after load + await Task.Delay(50); + } + + [Fact] + public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow() + { + await SeedPlanningWithChildAsync( + parentStatus: TaskStatus.Planning, + childStatus: TaskStatus.Queued, + parentId: "p1", + childId: "c1"); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, VirtualList("virtual:queued", "Queued")); + + Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); + Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); + } + + [Fact] + public async Task VirtualRunning_RunningChildOfPlanningParent_IsNotStandaloneRow() + { + await SeedPlanningWithChildAsync( + parentStatus: TaskStatus.Planning, + childStatus: TaskStatus.Running, + parentId: "p1", + childId: "c1"); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, VirtualList("virtual:running", "Running")); + + Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); + Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); + } + + [Fact] + public async Task Done_ChildOfOpenPlanningParent_StaysNestedUnderParent() + { + await SeedPlanningWithChildAsync( + parentStatus: TaskStatus.Planning, + childStatus: TaskStatus.Done, + parentId: "p1", + childId: "c1"); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + // Child with Done status under an open Planning parent should NOT go to CompletedItems + Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1"); + // Child should appear nested (IsChild == true) in OpenItems + Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild); + } + + [Fact] + public async Task Done_ChildOfDonePlanningParent_MovesToCompleted() + { + await SeedPlanningWithChildAsync( + parentStatus: TaskStatus.Done, + childStatus: TaskStatus.Done, + parentId: "p1", + childId: "c1"); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + Assert.Contains(vm.CompletedItems, r => r.Id == "p1"); + Assert.Contains(vm.CompletedItems, r => r.Id == "c1"); + } +}