feat(ui): surface depends_on blocking in status chip, chain label, and detail pane

Usability-Audit 2026-09-10 found three gaps versus the dependency-chain-display
spec: a Queued task blocked on an unmet task_dependencies edge rendered the same
chip as one about to run, a cross-list predecessor silently dropped the
"after #N" chip, and the detail pane showed nothing about a wait at all.

- TaskRowViewModel gains IsBlockedByDependency/DependencyWaitLabel (extrinsic,
  assigned by TasksIslandViewModel like the existing chain fields); IsQueued/
  IsWaiting/StatusChipClass/StatusLabel/StatusChipTooltip now account for it
  alongside the existing BlockedByTaskId planning-chain link.
- TasksIslandViewModel.ApplyDependencyWaitState computes the blocked flag and
  wait label per row; a new RefreshExternalDependencyCacheAsync backfills
  Number/Title/Status for predecessors not loaded into the current list's
  Items (reusing the already-open DbContext, no new hub method), so
  BuildAfterLabel/blocking both resolve a listenfremder Vorgänger correctly.
- New "waiting" chip style (IslandStyles.axaml) and locale keys (en+de);
  TaskHeaderBar shows a "Waiting on #N" line bound straight to the same row.

Tests: TasksIslandChainTests (blocked/unblocked chip, cross-list predecessor),
DetailsIslandDependencyWaitTests (new), TaskRowViewModelNotificationsTests
updated for the new notification set.
This commit is contained in:
mika kuns
2026-09-10 13:32:36 +02:00
parent c1b133ff90
commit 4cf601e423
10 changed files with 363 additions and 22 deletions
@@ -266,4 +266,93 @@ public class TasksIslandChainTests : IDisposable
Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel);
Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel);
}
// Usability-Audit 2026-09-10: a Queued task blocked on an unmet task_dependencies edge used to
// render the same "queued" chip as one that's actually next up to run.
[Fact]
public async Task QueuedTaskWithUnmetDependency_ShowsWaitingChipAndTooltip()
{
var numbers = await SeedAsync(
("predecessor", TaskStatus.Idle, null, null, 0),
("dependent", TaskStatus.Queued, "predecessor", null, 1));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var dependent = vm.Items.First(r => r.Id == "dependent");
Assert.True(dependent.IsWaiting);
Assert.False(dependent.IsQueued);
Assert.Equal("waiting", dependent.StatusChipClass);
Assert.NotNull(dependent.StatusChipTooltip);
Assert.Contains(numbers["predecessor"].ToString(), dependent.StatusChipTooltip);
}
[Fact]
public async Task QueuedTaskWithSatisfiedDependency_ShowsPlainQueuedChip()
{
await SeedAsync(
("predecessor", TaskStatus.Done, null, null, 0),
("dependent", TaskStatus.Queued, "predecessor", null, 1));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var dependent = vm.Items.First(r => r.Id == "dependent");
Assert.False(dependent.IsWaiting);
Assert.True(dependent.IsQueued);
Assert.Equal("queued", dependent.StatusChipClass);
Assert.Null(dependent.DependencyWaitLabel);
}
// Usability-Audit 2026-09-10: BuildAfterLabel only resolved predecessors already loaded into
// Items, so a listenfremder Vorgänger (different list) silently dropped both the "after #N"
// chip and the blocked chip. RefreshExternalDependencyCacheAsync backfills Number/Title/Status
// for exactly those ids via one extra query on the already-open DbContext.
[Fact]
public async Task CrossListPredecessor_BlocksAndRendersAfterLabel()
{
var headNumber = TestTaskNumbers.Next();
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "list2", Name = "Other", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Number = headNumber,
Id = "head",
ListId = "list1",
Title = "head",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Idle,
SortOrder = 0,
});
db.Tasks.Add(new TaskEntity
{
Number = TestTaskNumbers.Next(),
Id = "dependent",
ListId = "list2",
Title = "dependent",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Queued,
SortOrder = 0,
});
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "dependent", DependsOnTaskId = "head" });
await db.SaveChangesAsync();
}
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list2", "Other"));
var dependent = vm.Items.First(r => r.Id == "dependent");
Assert.True(dependent.IsWaiting);
Assert.False(dependent.IsQueued);
Assert.Equal("waiting", dependent.StatusChipClass);
Assert.NotNull(dependent.ChainAfterLabel);
Assert.Contains($"#{headNumber}", dependent.ChainAfterLabel);
Assert.NotNull(dependent.DependencyWaitLabel);
Assert.Contains($"#{headNumber}", dependent.DependencyWaitLabel);
}
}