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

@@ -14,10 +14,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>

View File

@@ -110,10 +110,12 @@
"ctxRunInteractively": "Interaktiv ausführen",
"ctxOpenPlanningSession": "Planungssitzung öffnen",
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
"ctxFinalizePlanningSession": "Plan finalisieren",
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
"ctxScheduleFor": "Planen für...",
"ctxClearSchedule": "Zeitplan entfernen",
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
"badgeDraft": "ENTWURF",
"badgePlanned": "GEPLANT",
"approve": "Genehmigen",
@@ -421,6 +423,8 @@
"shell": {
"menu": {
"help": "Hilfe",
"worker": "Worker",
"repositories": "Repositories",
"checkForUpdates": "Nach Updates suchen",
"restartWorker": "Worker neu starten",
"worktrees": "Worktrees…",

View File

@@ -110,10 +110,12 @@
"ctxRunInteractively": "Run interactively",
"ctxOpenPlanningSession": "Open planning Session",
"ctxResumePlanningSession": "Resume planning Session",
"ctxFinalizePlanningSession": "Finalize plan",
"ctxDiscardPlanningSession": "Discard planning session",
"ctxQueueSubtasks": "Queue subtasks sequentially",
"ctxScheduleFor": "Schedule for...",
"ctxClearSchedule": "Clear schedule",
"ctxRemoveFromMyDay": "Remove from My Day",
"ctxAddToMyDay": "Add to My Day",
"badgeDraft": "DRAFT",
"badgePlanned": "PLANNED",
"approve": "Approve",
@@ -421,6 +423,8 @@
"shell": {
"menu": {
"help": "Help",
"worker": "Worker",
"repositories": "Repositories",
"checkForUpdates": "Check for updates",
"restartWorker": "Restart worker",
"worktrees": "Worktrees…",

View File

@@ -7,7 +7,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />

View File

@@ -871,14 +871,9 @@
<Setter Property="Padding" Value="8,5" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style>
<Style Selector="Border.subtask-row:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
</Style>
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
<Setter Property="Opacity" Value="0.5" />

View File

@@ -7,24 +7,15 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class NoteBulletViewModel : ViewModelBase
{
private readonly Func<NoteBulletViewModel, Task> _save;
private readonly Func<NoteBulletViewModel, Task> _delete;
public string Id { get; }
[ObservableProperty] private string _text;
public NoteBulletViewModel(string id, string text,
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
public NoteBulletViewModel(string id, string text)
{
Id = id;
_text = text;
_save = save;
_delete = delete;
}
[RelayCommand] private Task Save() => _save(this);
[RelayCommand] private Task Delete() => _delete(this);
}
public sealed partial class NotesEditorViewModel : ViewModelBase
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
}
private NoteBulletViewModel MakeBullet(string id, string text) =>
new(id, text, SaveBulletAsync, DeleteBulletAsync);
new(id, text);
[RelayCommand]
private async Task AddBullet()
@@ -73,11 +64,17 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
private async Task DeleteBulletAsync(NoteBulletViewModel b)
[RelayCommand]
private async Task CommitBullet(NoteBulletViewModel? b)
{
await _api.DeleteAsync(b.Id);
Bullets.Remove(b);
if (b is null) return;
var text = b.Text?.Trim() ?? "";
if (text.Length == 0)
{
await _api.DeleteAsync(b.Id);
Bullets.Remove(b);
return;
}
await _api.UpdateAsync(b.Id, text);
}
}

View File

@@ -31,6 +31,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
[ObservableProperty] private bool _parentInView = true;
[ObservableProperty] private int _roadblockCount;
[ObservableProperty] private bool _isRefining;
@@ -46,9 +47,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|| HasPlanningChildren;
// A child only reads as a child while its parent shares the current view. When the parent is
// absent (removed from My Day, or daily-prep placed a lone child there), the row renders as a
// normal top-level task instead of an orphaned, indented Draft.
public bool ShowAsChild => IsChild && ParentInView;
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
&& PlanningPhase == PlanningPhase.None
@@ -74,13 +79,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
// it must be finalized first.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized);
&& (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active;
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
// Drives the routing inside SendToQueue, not a separate menu entry.
public bool CanQueuePlan => !IsChild && HasPlanningChildren
&& PlanningPhase == PlanningPhase.Finalized
&& !HasQueuedSubtasks;
// User-triggered finalize for a planning parent whose session was closed before finalizing.
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
public bool HasSchedule => ScheduledFor.HasValue;
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
// today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay).
public bool CanAddToMyDay => !IsMyDay && !Done;
public bool HasRoadblock => RoadblockCount > 0;
public string RoadblockTooltip => RoadblockCount == 1
? "1 roadblock reported during the run — see details"
@@ -135,12 +150,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsAgentSuggested));
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnParentInViewChanged(bool value)
{
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
}
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
partial void OnParentFinalizedChanged(bool value)
@@ -159,6 +182,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanFinalizePlanning));
OnPropertyChanged(nameof(CanRefine));
}
@@ -185,7 +210,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnDoneChanged(bool value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(CanAddToMyDay));
}
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
partial void OnScheduledForChanged(DateTime? value)
{
OnPropertyChanged(nameof(IsOverdue));

View File

@@ -334,6 +334,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Items is already ordered by SortOrder from the DB query.
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
var visibleIds = Items.Select(r => r.Id).ToHashSet();
// A child reads as a child only while its parent is in the view. Flag orphans so they
// render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
foreach (var r in Items)
r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
bool IsTopLevel(TaskRowViewModel r) =>
!r.IsChild
|| string.IsNullOrEmpty(r.ParentTaskId)
@@ -571,6 +575,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task AddToMyDayAsync(TaskRowViewModel? row)
{
if (row is null || row.IsMyDay) return;
row.IsMyDay = true;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsMyDay = true;
await db.SaveChangesAsync();
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
{
if (row is null) return;
row.IsMyDay = false;
await using var db = await _dbFactory.CreateDbContextAsync();
// Removing a parent takes its whole plan off My Day: clear the task and every child, so no
// orphaned child is left behind (independently-IsMyDay children included). A leaf child has
// no children of its own, so this collapses to just clearing the row itself.
var affected = await db.Tasks
.Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
.ToListAsync();
foreach (var t in affected)
t.IsMyDay = false;
if (affected.Count > 0)
await db.SaveChangesAsync();
if (_currentList?.Id == "smart:my-day")
{
var drop = Items
.Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
.ToList();
foreach (var r in drop)
Items.Remove(r);
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
{
if (_worker is null) return;
@@ -582,6 +632,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
if (row is null || row.IsRunning) return;
// A finalized planning parent queues its plan (children sequentially), not itself.
if (row.CanQueuePlan)
{
await QueuePlanningSubtasksAsync(row);
return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;

View File

@@ -29,7 +29,9 @@ public partial class DescriptionStepsCard : UserControl
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
row.IsEditing = false;
if (sender is TextBox { DataContext: SubtaskRowViewModel row }
&& DataContext is DetailsIslandViewModel vm
&& vm.CommitSubtaskEditCommand.CanExecute(row))
vm.CommitSubtaskEditCommand.Execute(row);
}
}

View File

@@ -35,6 +35,13 @@ public partial class DetailsIslandView : UserControl
if (h <= 0) return;
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
// The description sits in an Auto row, which measures its cell with
// infinite height — so the card's inner ScrollViewer thinks everything
// fits and never scrolls. Bounding the card itself gives that
// ScrollViewer a finite measure constraint so it engages once the
// content exceeds 2/3 of the island. (RowDefinition.MaxHeight above only
// clamps the drag and the final row height, not the measure constraint.)
DescriptionCard.MaxHeight = h * 2.0 / 3.0;
}
private void OnDataContextChanged(object? sender, EventArgs e)

View File

@@ -28,11 +28,8 @@
<ItemsControl ItemsSource="{Binding Bullets}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NoteBulletViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
<TextBox Grid.Column="0" Text="{Binding Text}"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr notes.save}" Command="{Binding SaveCommand}"/>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr notes.delete}" Command="{Binding DeleteCommand}"/>
</Grid>
<TextBox Text="{Binding Text}" Margin="0,2"
LostFocus="OnBulletLostFocus"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

View File

@@ -1,8 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl
{
public NotesEditorView() => InitializeComponent();
private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: NoteBulletViewModel bullet }
&& DataContext is NotesEditorViewModel vm
&& vm.CommitBulletCommand.CanExecute(bullet))
vm.CommitBulletCommand.Execute(bullet);
}
}

View File

@@ -20,8 +20,8 @@
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<!-- Indent track (only visible for child tasks) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
HorizontalAlignment="Right" Margin="0,4"/>
</Border>
@@ -56,17 +56,23 @@
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
Click="OnFinalizePlanningSessionClick"
IsVisible="{Binding CanFinalizePlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxQueueSubtasks}"
Click="OnQueuePlanningSubtasksClick"
IsVisible="{Binding CanQueuePlan}"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
IsVisible="{Binding CanAddToMyDay}"
Click="OnAddToMyDayClick"/>
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
IsVisible="{Binding IsMyDay}"
Click="OnRemoveFromMyDayClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">

View File

@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
await vm.ClearScheduleCommand.ExecuteAsync(row);
}
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.AddToMyDayCommand.ExecuteAsync(row);
}
private async void OnRemoveFromMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
}
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
private async void OnFinalizePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)

View File

@@ -57,18 +57,26 @@
<Menu Margin="12,0,0,0"
Background="Transparent"
VerticalAlignment="Center">
<MenuItem Header="{loc:Tr shell.menu.worker}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.help}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
</MenuItem>
</Menu>
</StackPanel>

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");
}
}