diff --git a/src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs b/src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs index 1ae68b8f..c1a08783 100644 --- a/src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs +++ b/src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs @@ -1,3 +1,5 @@ +using ClaudeDo.Data.Models; + namespace ClaudeDo.Ui.Services; public sealed record PrimeScheduleDto( @@ -6,7 +8,8 @@ public sealed record PrimeScheduleDto( TimeSpan TimeOfDay, bool Enabled, DateTimeOffset? LastRunAt, - string? PromptOverride); + string? PromptOverride, + PrimeActionKind Kind); public sealed record PrimeFiredEvent( Guid ScheduleId, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs index c161b957..fd0f00a9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs @@ -1,4 +1,7 @@ using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -11,13 +14,36 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase private readonly HashSet _initialIds = new(); [ObservableProperty] private int _dailyPrepMaxTasks = 5; + [ObservableProperty] private bool _anyFillMyDay; public ObservableCollection Rows { get; } = new(); - public PrimeClaudeTabViewModel(IPrimeScheduleApi api) => _api = api; + public PrimeClaudeTabViewModel(IPrimeScheduleApi api) + { + _api = api; + Rows.CollectionChanged += OnRowsChanged; + } + + private void OnRowsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems is not null) + foreach (PrimeScheduleRowViewModel r in e.OldItems) r.PropertyChanged -= OnRowChanged; + if (e.NewItems is not null) + foreach (PrimeScheduleRowViewModel r in e.NewItems) r.PropertyChanged += OnRowChanged; + UpdateAnyFillMyDay(); + } + + private void OnRowChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PrimeScheduleRowViewModel.Kind)) UpdateAnyFillMyDay(); + } + + private void UpdateAnyFillMyDay() => + AnyFillMyDay = Rows.Any(r => r.Kind == PrimeActionKind.FillMyDay); public async Task LoadAsync() { + foreach (var r in Rows) r.PropertyChanged -= OnRowChanged; Rows.Clear(); _initialIds.Clear(); var list = await _api.ListAsync(); @@ -36,6 +62,8 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day."; if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1)) return "Time must be between 00:00 and 23:59."; + if (r.Kind == PrimeActionKind.Custom && string.IsNullOrWhiteSpace(r.PromptOverride)) + return $"Schedule {r.TimeOfDay:hh\\:mm}: a custom action needs a prompt."; } return null; } @@ -60,7 +88,8 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase TimeOfDay: new TimeSpan(7, 0, 0), Enabled: true, LastRunAt: null, - PromptOverride: null); + PromptOverride: null, + Kind: PrimeActionKind.Ping); Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false)); } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs index b321253a..b3187e0b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs @@ -1,4 +1,6 @@ using System.Globalization; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; @@ -22,6 +24,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase [ObservableProperty] private TimeSpan _timeOfDay; [ObservableProperty] private DateTimeOffset? _lastRunAt; [ObservableProperty] private string? _promptOverride; + [ObservableProperty] private PrimeActionKind _kind; public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—"; @@ -41,6 +44,50 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase } } + /// Per-row group name. A shared GroupName would make every schedule in the + /// ItemsControl share one selection. + public string RadioGroup => $"prime-kind-{Id}"; + + // Three booleans rather than an enum-to-bool converter: RadioButton.IsChecked binds + // TwoWay to a bool, and compiled bindings stay simple. + public bool IsPing + { + get => Kind == PrimeActionKind.Ping; + set { if (value) Kind = PrimeActionKind.Ping; } + } + + public bool IsFillMyDay + { + get => Kind == PrimeActionKind.FillMyDay; + set { if (value) Kind = PrimeActionKind.FillMyDay; } + } + + public bool IsCustom + { + get => Kind == PrimeActionKind.Custom; + set { if (value) Kind = PrimeActionKind.Custom; } + } + + public bool ShowPrompt => Kind != PrimeActionKind.Ping; + + public string PromptLabel => Kind == PrimeActionKind.Custom + ? Loc.T("settings.prime.customPromptLabel") + : Loc.T("settings.prime.promptOverrideLabel"); + + public string PromptPlaceholder => Kind == PrimeActionKind.Custom + ? Loc.T("settings.prime.customPromptPlaceholder") + : Loc.T("settings.prime.promptOverridePlaceholder"); + + partial void OnKindChanged(PrimeActionKind value) + { + OnPropertyChanged(nameof(IsPing)); + OnPropertyChanged(nameof(IsFillMyDay)); + OnPropertyChanged(nameof(IsCustom)); + OnPropertyChanged(nameof(ShowPrompt)); + OnPropertyChanged(nameof(PromptLabel)); + OnPropertyChanged(nameof(PromptPlaceholder)); + } + partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel)); partial void OnTimeOfDayChanged(TimeSpan value) => OnPropertyChanged(nameof(TimeText)); @@ -60,6 +107,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase TimeOfDay = dto.TimeOfDay; LastRunAt = dto.LastRunAt; PromptOverride = dto.PromptOverride; + Kind = dto.Kind; } public int DaysMask() @@ -77,5 +125,5 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase public PrimeScheduleDto ToDto() => new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, - string.IsNullOrWhiteSpace(PromptOverride) ? null : PromptOverride); + string.IsNullOrWhiteSpace(PromptOverride) ? null : PromptOverride, Kind); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs index a8c9fa6a..e16cd8c0 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals.Settings; @@ -27,8 +28,9 @@ public class PrimeClaudeTabViewModelTests public Task DeleteAsync(Guid id) => Task.CompletedTask; } - private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) => - new(id, days, time, true, null, null); + private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time, + PrimeActionKind kind = PrimeActionKind.FillMyDay, string? prompt = null) => + new(id, days, time, true, null, prompt, kind); [Fact] public async Task Load_Populates_Rows() @@ -116,4 +118,92 @@ public class PrimeClaudeTabViewModelTests var ex = await Assert.ThrowsAsync(() => vm.SaveAsync()); Assert.Equal(api.ExceptionMessage, ex.Message); } + + [Fact] + public void AddSchedule_Defaults_To_Ping() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + + Assert.Equal(PrimeActionKind.Ping, vm.Rows[0].Kind); + Assert.True(vm.Rows[0].IsPing); + Assert.False(vm.Rows[0].IsFillMyDay); + Assert.False(vm.Rows[0].IsCustom); + } + + [Fact] + public void Setting_IsCustom_Flips_Kind_And_Clears_The_Others() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + var row = vm.Rows[0]; + + row.IsCustom = true; + + Assert.Equal(PrimeActionKind.Custom, row.Kind); + Assert.False(row.IsPing); + Assert.False(row.IsFillMyDay); + } + + [Fact] + public void Row_RadioGroup_Is_Unique_Per_Row() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + vm.AddScheduleCommand.Execute(null); + + Assert.NotEqual(vm.Rows[0].RadioGroup, vm.Rows[1].RadioGroup); + } + + [Fact] + public void Validate_Rejects_Custom_Without_A_Prompt() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + vm.Rows[0].IsCustom = true; + vm.Rows[0].PromptOverride = " "; + + Assert.NotNull(vm.Validate()); + } + + [Fact] + public void Validate_Accepts_Custom_With_A_Prompt() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + vm.Rows[0].IsCustom = true; + vm.Rows[0].PromptOverride = "queue the oldest task"; + + Assert.Null(vm.Validate()); + } + + [Fact] + public void AnyFillMyDay_Tracks_Kind_And_Row_Changes() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + Assert.False(vm.AnyFillMyDay); + + vm.AddScheduleCommand.Execute(null); + Assert.False(vm.AnyFillMyDay); + + vm.Rows[0].IsFillMyDay = true; + Assert.True(vm.AnyFillMyDay); + + vm.RemoveScheduleCommand.Execute(vm.Rows[0]); + Assert.False(vm.AnyFillMyDay); + } + + [Fact] + public async Task Save_Round_Trips_The_Kind() + { + var api = new FakeApi(); + var vm = new PrimeClaudeTabViewModel(api); + vm.AddScheduleCommand.Execute(null); + vm.Rows[0].IsCustom = true; + vm.Rows[0].PromptOverride = "do the thing"; + + await vm.SaveAsync(); + + Assert.Equal(PrimeActionKind.Custom, api.Upserts[0].Kind); + } }