feat(ui): My Day actions, orphan-aware grouping, menu restructure

Pending UI work:
- My Day add/remove context actions on task rows (parent removal cascades to children)
- orphan-aware grouping: a child whose parent isn't in view renders as a top-level row, not an indented draft
- shell menu restructure (Worker / Repositories submenus); 'Finalize plan' action, drop 'Queue subtasks sequentially'
- notes editor refinements
- subtask-row hover tweak (Surface3, no transition)
- bump Avalonia 12.0.0 -> 12.0.4
This commit is contained in:
Mika Kuns
2026-06-18 16:22:29 +02:00
parent 43fb506e87
commit 4847c5c0a4
19 changed files with 384 additions and 58 deletions

View File

@@ -10,8 +10,8 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />

View File

@@ -62,7 +62,7 @@ public class NotesEditorViewModelTests
}
[Fact]
public async Task DeleteBullet_RemovesFromStoreAndList()
public async Task CommitBullet_EmptyText_RemovesFromStoreAndList()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
@@ -70,9 +70,26 @@ public class NotesEditorViewModelTests
vm.NewBulletText = "weg damit";
await vm.AddBulletCommand.ExecuteAsync(null);
await vm.Bullets[0].DeleteCommand.ExecuteAsync(null);
vm.Bullets[0].Text = " ";
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
Assert.Empty(vm.Bullets);
Assert.Empty(api.Store);
}
[Fact]
public async Task CommitBullet_NonEmptyText_PersistsEdit()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "original";
await vm.AddBulletCommand.ExecuteAsync(null);
vm.Bullets[0].Text = "geändert";
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
Assert.Single(vm.Bullets);
Assert.Equal("geändert", api.Store[0].Text);
}
}

View File

@@ -109,4 +109,83 @@ public class TaskRowViewModelPlanningTests
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
Assert.False(vm.CanQueuePlan);
}
[Fact]
public void ActivePlanningParent_CannotSendToQueue()
{
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
vm.HasPlanningChildren = true;
Assert.False(vm.CanSendToQueue);
}
[Fact]
public void FinalizedParentWithChildren_CanSendToQueue()
{
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
vm.HasPlanningChildren = true;
Assert.True(vm.CanSendToQueue);
}
[Fact]
public void ActivePlanning_CanFinalizePlanning()
{
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
Assert.True(vm.CanFinalizePlanning);
}
[Fact]
public void FinalizedPlanning_CannotFinalizePlanning()
{
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
Assert.False(vm.CanFinalizePlanning);
}
[Fact]
public void PlainIdle_CannotFinalizePlanning()
{
var vm = MakeRow(TaskStatus.Idle);
Assert.False(vm.CanFinalizePlanning);
}
[Fact]
public void ChildWithParentInView_RendersAsChild()
{
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
Assert.True(vm.ParentInView); // default
Assert.True(vm.ShowAsChild);
Assert.True(vm.IsDraft);
}
[Fact]
public void OrphanedChild_RendersFlat_WithNoDraftOrPlannedBadge()
{
// Parent absent from the view (e.g. removed from My Day, or daily-prep placed a lone
// child there): the row stays a child by data but must read as a normal top-level task.
var draftOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
draftOrphan.ParentInView = false;
Assert.True(draftOrphan.IsChild);
Assert.False(draftOrphan.ShowAsChild);
Assert.False(draftOrphan.IsDraft);
var plannedOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
plannedOrphan.ParentFinalized = true;
plannedOrphan.ParentInView = false;
Assert.False(plannedOrphan.ShowAsChild);
Assert.False(plannedOrphan.IsPlanned);
Assert.False(plannedOrphan.IsDraft);
}
[Fact]
public void CanAddToMyDay_TrueOnlyWhenNotInMyDayAndNotDone()
{
var row = MakeRow(TaskStatus.Idle);
Assert.True(row.CanAddToMyDay); // idle, not yet in My Day
row.IsMyDay = true;
Assert.False(row.CanAddToMyDay); // already in My Day
row.IsMyDay = false;
row.Done = true;
Assert.False(row.CanAddToMyDay); // done tasks don't belong in today's focus
}
}

View File

@@ -3,6 +3,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Worker.Tests.Infrastructure;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Xunit;
@@ -223,4 +224,105 @@ public class TasksIslandViewModelPlanningTests
vm.ToggleExpandCommand.Execute(parent);
Assert.Contains(child, vm.OpenItems);
}
[Fact]
public void Regroup_ChildWithoutParentInView_ReadsAsTopLevelOrphan()
{
// Parent not in the view: the child must be flagged orphan so it renders flat, and it
// surfaces as a normal top-level row rather than an indented Draft.
var orphan = MakeRow("c1", TaskStatus.Idle, parentId: "missing-parent");
var (vm, _) = VmFactory.Create([orphan]);
Assert.False(orphan.ParentInView);
Assert.False(orphan.ShowAsChild);
Assert.False(orphan.IsDraft);
Assert.Contains(orphan, vm.OpenItems);
}
[Fact]
public void Regroup_ChildWithParentPresent_KeepsParentInView()
{
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
var child = MakeRow("c1", TaskStatus.Idle, "p1");
var (_, _) = VmFactory.Create([parent, child]);
Assert.True(child.ParentInView);
Assert.True(child.ShowAsChild);
}
}
// ── My Day add / remove (real DB) ─────────────────────────────────────────────
public sealed class TasksIslandViewModelMyDayTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task AddToMyDay_SetsIsMyDay()
{
var listId = Guid.NewGuid().ToString("N");
var taskId = Guid.NewGuid().ToString("N");
await using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Idle, IsMyDay = false,
});
await ctx.SaveChangesAsync();
}
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
var row = new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle, IsMyDay = false };
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.AddToMyDayCommand).ExecuteAsync(row);
await using var verify = _db.CreateContext();
var loaded = await verify.Tasks.FirstAsync(t => t.Id == taskId);
Assert.True(loaded.IsMyDay);
Assert.True(row.IsMyDay);
}
[Fact]
public async Task RemoveFromMyDay_OnParent_CascadesToEveryChild()
{
var listId = Guid.NewGuid().ToString("N");
var parentId = Guid.NewGuid().ToString("N");
var child1 = Guid.NewGuid().ToString("N");
var child2 = Guid.NewGuid().ToString("N");
await using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = parentId, ListId = listId, Title = "P", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, IsMyDay = true,
});
ctx.Tasks.Add(new TaskEntity
{
Id = child1, ListId = listId, Title = "C1", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
});
ctx.Tasks.Add(new TaskEntity
{
Id = child2, ListId = listId, Title = "C2", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
});
await ctx.SaveChangesAsync();
}
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
var parentRow = new TaskRowViewModel { Id = parentId, Status = TaskStatus.WaitingForChildren, IsMyDay = true };
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.RemoveFromMyDayCommand).ExecuteAsync(parentRow);
await using var verify = _db.CreateContext();
Assert.False(await verify.Tasks.AnyAsync(t => t.IsMyDay),
"removing the parent from My Day must clear IsMyDay on the parent and all its children");
}
}