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:
@@ -14,10 +14,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
|||||||
@@ -110,10 +110,12 @@
|
|||||||
"ctxRunInteractively": "Interaktiv ausführen",
|
"ctxRunInteractively": "Interaktiv ausführen",
|
||||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||||
|
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||||
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
||||||
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
|
|
||||||
"ctxScheduleFor": "Planen für...",
|
"ctxScheduleFor": "Planen für...",
|
||||||
"ctxClearSchedule": "Zeitplan entfernen",
|
"ctxClearSchedule": "Zeitplan entfernen",
|
||||||
|
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
|
||||||
|
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||||
"badgeDraft": "ENTWURF",
|
"badgeDraft": "ENTWURF",
|
||||||
"badgePlanned": "GEPLANT",
|
"badgePlanned": "GEPLANT",
|
||||||
"approve": "Genehmigen",
|
"approve": "Genehmigen",
|
||||||
@@ -421,6 +423,8 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
|
"worker": "Worker",
|
||||||
|
"repositories": "Repositories",
|
||||||
"checkForUpdates": "Nach Updates suchen",
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
"restartWorker": "Worker neu starten",
|
"restartWorker": "Worker neu starten",
|
||||||
"worktrees": "Worktrees…",
|
"worktrees": "Worktrees…",
|
||||||
|
|||||||
@@ -110,10 +110,12 @@
|
|||||||
"ctxRunInteractively": "Run interactively",
|
"ctxRunInteractively": "Run interactively",
|
||||||
"ctxOpenPlanningSession": "Open planning Session",
|
"ctxOpenPlanningSession": "Open planning Session",
|
||||||
"ctxResumePlanningSession": "Resume planning Session",
|
"ctxResumePlanningSession": "Resume planning Session",
|
||||||
|
"ctxFinalizePlanningSession": "Finalize plan",
|
||||||
"ctxDiscardPlanningSession": "Discard planning session",
|
"ctxDiscardPlanningSession": "Discard planning session",
|
||||||
"ctxQueueSubtasks": "Queue subtasks sequentially",
|
|
||||||
"ctxScheduleFor": "Schedule for...",
|
"ctxScheduleFor": "Schedule for...",
|
||||||
"ctxClearSchedule": "Clear schedule",
|
"ctxClearSchedule": "Clear schedule",
|
||||||
|
"ctxRemoveFromMyDay": "Remove from My Day",
|
||||||
|
"ctxAddToMyDay": "Add to My Day",
|
||||||
"badgeDraft": "DRAFT",
|
"badgeDraft": "DRAFT",
|
||||||
"badgePlanned": "PLANNED",
|
"badgePlanned": "PLANNED",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
@@ -421,6 +423,8 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
"worker": "Worker",
|
||||||
|
"repositories": "Repositories",
|
||||||
"checkForUpdates": "Check for updates",
|
"checkForUpdates": "Check for updates",
|
||||||
"restartWorker": "Restart worker",
|
"restartWorker": "Restart worker",
|
||||||
"worktrees": "Worktrees…",
|
"worktrees": "Worktrees…",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
|||||||
@@ -871,14 +871,9 @@
|
|||||||
<Setter Property="Padding" Value="8,5" />
|
<Setter Property="Padding" Value="8,5" />
|
||||||
<Setter Property="CornerRadius" Value="6" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="Transitions">
|
|
||||||
<Transitions>
|
|
||||||
<BrushTransition Property="Background" Duration="0:0:0.10"/>
|
|
||||||
</Transitions>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.subtask-row:pointerover">
|
<Style Selector="Border.subtask-row:pointerover">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
||||||
<Setter Property="Opacity" Value="0.5" />
|
<Setter Property="Opacity" Value="0.5" />
|
||||||
|
|||||||
@@ -7,24 +7,15 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
|
|
||||||
public sealed partial class NoteBulletViewModel : ViewModelBase
|
public sealed partial class NoteBulletViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly Func<NoteBulletViewModel, Task> _save;
|
|
||||||
private readonly Func<NoteBulletViewModel, Task> _delete;
|
|
||||||
|
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
[ObservableProperty] private string _text;
|
[ObservableProperty] private string _text;
|
||||||
|
|
||||||
public NoteBulletViewModel(string id, string text,
|
public NoteBulletViewModel(string id, string text)
|
||||||
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
|
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
_text = text;
|
_text = text;
|
||||||
_save = save;
|
|
||||||
_delete = delete;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private Task Save() => _save(this);
|
|
||||||
[RelayCommand] private Task Delete() => _delete(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class NotesEditorViewModel : ViewModelBase
|
public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||||
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
||||||
new(id, text, SaveBulletAsync, DeleteBulletAsync);
|
new(id, text);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddBullet()
|
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 NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
|
||||||
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
|
||||||
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
|
[RelayCommand]
|
||||||
|
private async Task CommitBullet(NoteBulletViewModel? b)
|
||||||
private async Task DeleteBulletAsync(NoteBulletViewModel b)
|
|
||||||
{
|
{
|
||||||
await _api.DeleteAsync(b.Id);
|
if (b is null) return;
|
||||||
Bullets.Remove(b);
|
var text = b.Text?.Trim() ?? "";
|
||||||
|
if (text.Length == 0)
|
||||||
|
{
|
||||||
|
await _api.DeleteAsync(b.Id);
|
||||||
|
Bullets.Remove(b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _api.UpdateAsync(b.Id, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||||
[ObservableProperty] private bool _showListChip = true;
|
[ObservableProperty] private bool _showListChip = true;
|
||||||
[ObservableProperty] private bool _parentFinalized;
|
[ObservableProperty] private bool _parentFinalized;
|
||||||
|
[ObservableProperty] private bool _parentInView = true;
|
||||||
[ObservableProperty] private int _roadblockCount;
|
[ObservableProperty] private int _roadblockCount;
|
||||||
[ObservableProperty] private bool _isRefining;
|
[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 IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
||||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||||
|| HasPlanningChildren;
|
|| 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).
|
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
|
||||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||||
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
|
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||||
|
|
||||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||||
&& PlanningPhase == PlanningPhase.None
|
&& PlanningPhase == PlanningPhase.None
|
||||||
@@ -74,13 +79,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
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
|
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).
|
// 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
|
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||||
&& PlanningPhase == PlanningPhase.Finalized
|
&& PlanningPhase == PlanningPhase.Finalized
|
||||||
&& !HasQueuedSubtasks;
|
&& !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;
|
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 bool HasRoadblock => RoadblockCount > 0;
|
||||||
public string RoadblockTooltip => RoadblockCount == 1
|
public string RoadblockTooltip => RoadblockCount == 1
|
||||||
? "1 roadblock reported during the run — see details"
|
? "1 roadblock reported during the run — see details"
|
||||||
@@ -135,12 +150,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsChild));
|
OnPropertyChanged(nameof(IsChild));
|
||||||
OnPropertyChanged(nameof(IsAgentSuggested));
|
OnPropertyChanged(nameof(IsAgentSuggested));
|
||||||
|
OnPropertyChanged(nameof(ShowAsChild));
|
||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
OnPropertyChanged(nameof(IsPlanned));
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
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 OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
|
||||||
|
|
||||||
partial void OnParentFinalizedChanged(bool value)
|
partial void OnParentFinalizedChanged(bool value)
|
||||||
@@ -159,6 +182,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||||
OnPropertyChanged(nameof(CanQueuePlan));
|
OnPropertyChanged(nameof(CanQueuePlan));
|
||||||
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
|
OnPropertyChanged(nameof(CanFinalizePlanning));
|
||||||
OnPropertyChanged(nameof(CanRefine));
|
OnPropertyChanged(nameof(CanRefine));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +210,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
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)
|
partial void OnScheduledForChanged(DateTime? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsOverdue));
|
OnPropertyChanged(nameof(IsOverdue));
|
||||||
|
|||||||
@@ -334,6 +334,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// Items is already ordered by SortOrder from the DB query.
|
// Items is already ordered by SortOrder from the DB query.
|
||||||
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
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) =>
|
bool IsTopLevel(TaskRowViewModel r) =>
|
||||||
!r.IsChild
|
!r.IsChild
|
||||||
|| string.IsNullOrEmpty(r.ParentTaskId)
|
|| string.IsNullOrEmpty(r.ParentTaskId)
|
||||||
@@ -571,6 +575,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
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)
|
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||||
{
|
{
|
||||||
if (_worker is null) return;
|
if (_worker is null) return;
|
||||||
@@ -582,6 +632,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || row.IsRunning) return;
|
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();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
if (entity is null) return;
|
if (entity is null) return;
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ public partial class DescriptionStepsCard : UserControl
|
|||||||
|
|
||||||
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
|
if (sender is TextBox { DataContext: SubtaskRowViewModel row }
|
||||||
row.IsEditing = false;
|
&& DataContext is DetailsIslandViewModel vm
|
||||||
|
&& vm.CommitSubtaskEditCommand.CanExecute(row))
|
||||||
|
vm.CommitSubtaskEditCommand.Execute(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ public partial class DetailsIslandView : UserControl
|
|||||||
if (h <= 0) return;
|
if (h <= 0) return;
|
||||||
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
|
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
|
||||||
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 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)
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
|||||||
@@ -28,11 +28,8 @@
|
|||||||
<ItemsControl ItemsSource="{Binding Bullets}">
|
<ItemsControl ItemsSource="{Binding Bullets}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:NoteBulletViewModel">
|
<DataTemplate x:DataType="vm:NoteBulletViewModel">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
|
<TextBox Text="{Binding Text}" Margin="0,2"
|
||||||
<TextBox Grid.Column="0" Text="{Binding Text}"/>
|
LostFocus="OnBulletLostFocus"/>
|
||||||
<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>
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
public partial class NotesEditorView : UserControl
|
public partial class NotesEditorView : UserControl
|
||||||
{
|
{
|
||||||
public NotesEditorView() => InitializeComponent();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||||
|
|
||||||
<!-- Indent track (only visible for child tasks) -->
|
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
|
||||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
|
||||||
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||||
HorizontalAlignment="Right" Margin="0,4"/>
|
HorizontalAlignment="Right" Margin="0,4"/>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -56,17 +56,23 @@
|
|||||||
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
|
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
|
||||||
Click="OnResumePlanningSessionClick"
|
Click="OnResumePlanningSessionClick"
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
|
||||||
|
Click="OnFinalizePlanningSessionClick"
|
||||||
|
IsVisible="{Binding CanFinalizePlanning}"/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
|
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
|
||||||
Click="OnDiscardPlanningSessionClick"
|
Click="OnDiscardPlanningSessionClick"
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxQueueSubtasks}"
|
|
||||||
Click="OnQueuePlanningSubtasksClick"
|
|
||||||
IsVisible="{Binding CanQueuePlan}"/>
|
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
|
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
|
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
|
||||||
IsVisible="{Binding HasSchedule}"
|
IsVisible="{Binding HasSchedule}"
|
||||||
Click="OnClearScheduleClick"/>
|
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>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
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)
|
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
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)
|
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)
|
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -57,18 +57,26 @@
|
|||||||
<Menu Margin="12,0,0,0"
|
<Menu Margin="12,0,0,0"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
VerticalAlignment="Center">
|
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}"
|
<MenuItem Header="{loc:Tr shell.menu.help}"
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
Foreground="{DynamicResource TextDimBrush}">
|
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.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
|
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class NotesEditorViewModelTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteBullet_RemovesFromStoreAndList()
|
public async Task CommitBullet_EmptyText_RemovesFromStoreAndList()
|
||||||
{
|
{
|
||||||
var api = new FakeNotes();
|
var api = new FakeNotes();
|
||||||
var vm = new NotesEditorViewModel(api);
|
var vm = new NotesEditorViewModel(api);
|
||||||
@@ -70,9 +70,26 @@ public class NotesEditorViewModelTests
|
|||||||
vm.NewBulletText = "weg damit";
|
vm.NewBulletText = "weg damit";
|
||||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
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(vm.Bullets);
|
||||||
Assert.Empty(api.Store);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,83 @@ public class TaskRowViewModelPlanningTests
|
|||||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||||
Assert.False(vm.CanQueuePlan);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Models;
|
|||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -223,4 +224,105 @@ public class TasksIslandViewModelPlanningTests
|
|||||||
vm.ToggleExpandCommand.Execute(parent);
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
Assert.Contains(child, vm.OpenItems);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user