refactor(ui): flatten TasksIsland's task list into one virtualized ListBox

Replace the three ItemsControl sections (Overdue/Open/Completed) with a single
flat Rows collection (HeaderRow | TaskRowViewModel) bound to a ListBox +
VirtualizingStackPanel, so the realized container count stays bounded instead
of growing with the list (measured: ~7-8 realized containers at any scroll
position with 1000 source rows). Group headers become regular row entries,
each section is simply omitted from Rows when empty, and the Completed
section's Clear-completed action moves onto the header row itself.

SectionFor/ReorderAsync/FindNextInSameSection now read a row's section off
the nearest preceding HeaderRow in Rows instead of three separate bound
collections, and ScrollSelectedIntoView goes through ListBox.ScrollIntoView so
it still works for rows outside the realized window. Drag/reorder behavior,
grouping/counts, and the Clear-completed button are unchanged.

Auto-scroll-while-dragging and the cross-section reorder bug are explicitly
out of scope (follow-up tasks).
This commit is contained in:
mika kuns
2026-08-10 16:18:55 +02:00
parent 514d6111fe
commit ec6c230822
6 changed files with 369 additions and 139 deletions
@@ -211,6 +211,19 @@ public class TasksIslandViewModelPlanningTests
PlanningPhase phase = PlanningPhase.None)
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
// vm.Rows is the flat HeaderRow|TaskRowViewModel union — a row is "in the Open section" when
// it's present in Rows and the nearest preceding HeaderRow (if any) isn't Overdue/Completed.
// HasAction uniquely identifies the Completed header (its Clear-completed button).
private static bool IsVisibleInOpen(TasksIslandViewModel vm, TaskRowViewModel row)
{
var idx = vm.Rows.IndexOf(row);
if (idx < 0) return false;
for (int i = idx - 1; i >= 0; i--)
if (vm.Rows[i] is HeaderRow h)
return !h.IsOverdue && !h.HasAction;
return true;
}
[Fact]
public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
{
@@ -221,16 +234,16 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child1, child2]);
// Collapsed by default — children hidden, parent still present
Assert.DoesNotContain(child1, vm.OpenItems);
Assert.DoesNotContain(child2, vm.OpenItems);
Assert.Contains(parent, vm.OpenItems);
Assert.False(IsVisibleInOpen(vm, child1));
Assert.False(IsVisibleInOpen(vm, child2));
Assert.True(IsVisibleInOpen(vm, parent));
// Expand the parent
vm.ToggleExpandCommand.Execute(parent);
// Children now visible
Assert.Contains(child1, vm.OpenItems);
Assert.Contains(child2, vm.OpenItems);
Assert.True(IsVisibleInOpen(vm, child1));
Assert.True(IsVisibleInOpen(vm, child2));
}
[Fact]
@@ -268,15 +281,15 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child]);
// Collapsed by default
Assert.DoesNotContain(child, vm.OpenItems);
Assert.False(IsVisibleInOpen(vm, child));
// Expand
vm.ToggleExpandCommand.Execute(parent);
Assert.Contains(child, vm.OpenItems);
Assert.True(IsVisibleInOpen(vm, child));
// Collapse again
vm.ToggleExpandCommand.Execute(parent);
Assert.DoesNotContain(child, vm.OpenItems);
Assert.False(IsVisibleInOpen(vm, child));
}
[Fact]
@@ -291,7 +304,7 @@ public class TasksIslandViewModelPlanningTests
Assert.False(orphan.ParentInView);
Assert.False(orphan.ShowAsChild);
Assert.False(orphan.IsDraft);
Assert.Contains(orphan, vm.OpenItems);
Assert.True(IsVisibleInOpen(vm, orphan));
}
[Fact]