From 3549a939267fd46275e0e4f8bf9b7d2a7cdd6c8c Mon Sep 17 00:00:00 2001 From: laurentiu021 Date: Thu, 17 Sep 2026 17:57:01 +0300 Subject: [PATCH] fix: say that Scheduled Maintenance keeps one schedule, before Save replaces it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab registers a single Windows task at a fixed name, so Save does not add a second schedule — it overwrites the first. Nothing said so. The header mentioned "one Windows scheduled task" in passing, which reads as an implementation detail rather than a limit that affects you. The Configure card offered an action and a time as though each Save were a new entry. And the confirmation dialog said "This creates a Windows scheduled task" even when one already existed, so the dialog whose whole purpose is to stop an unwanted change actively concealed which change it was. Someone who wanted a weekly cleanup AND a monthly standby purge would have set the second, silently lost the first, and seen the tab report the survivor as though nothing had gone (#1509). Three statements of the same fact, at the three moments it matters: - the header now says SysManager keeps one schedule at a time and that saving a new one replaces the previous one; - a line beside the Save button says the rule, and changes to "saving this replaces the one above" as soon as a schedule exists, so it reads as a warning only when there is something to lose; - the confirmation asks a different question in each case. Replacing says so in those words and names the time the old schedule was next due — the only detail the status read-back has, since it reports state and times rather than which action Windows is holding, and it comes from the same read the card above displays, so the dialog and the card cannot disagree. Supporting several schedules was considered and deliberately not done, and the CHANGELOG says why: the design rests on touching exactly one task by name, which is what keeps the feature from needing administrator rights and what makes it incapable of disturbing anything else Windows has scheduled. No new CLI verbs and no new actions, so the headless surface is unchanged. 5 tests, asserted through DialogAnswer rather than the file's older hand-rolled DialogService swap, because for this gate the WORDING is the behaviour and the old helper cannot read it. Red ritual, four mutations, each red for the right reason and green after restore: the pre-fix single confirmation text (2 red — the replace wording and the create wording, since even the create case now states the rule); the note stopping to depend on whether a schedule exists (1); the replace text dropping the next run (1); the dialog title no longer distinguishing replace from create (1). The "declining leaves the task alone" test passes either way and is a regression guard on a path that had no test, not a proof of this change. Unit suite 5798 passed, 0 failed. All four projects build with 0 warnings. Closes #1509 --- CHANGELOG.md | 28 ++++ README.md | 5 + .../ScheduledMaintenanceViewModelTests.cs | 120 ++++++++++++++++++ SysManager/SysManager/SysManager.csproj | 6 +- .../ScheduledMaintenanceViewModel.cs | 61 ++++++++- .../Views/ScheduledMaintenanceView.xaml | 13 +- 6 files changed, 225 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e064df..947999d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,34 @@ That paragraph is not decoration: the release workflow copies each entry verbati the GitHub release body and the announcement discussion, so it is the first thing a prospective user reads. CI fails a pull request whose newest entry is missing it. +## [1.109.4] - 2026-09-17 + +**Saving a second maintenance schedule quietly threw away the first.** Scheduled Maintenance keeps one +schedule, by design — it is what makes it safe to set up without administrator rights and impossible for it +to disturb anything else Windows has scheduled. But nothing on the page said so. Pick an action and a time, +press Save, and the schedule you already had was gone, with the tab then reporting the new one as though +nothing had happened. The page now says there is one schedule, and the confirmation says plainly that Save +is replacing the existing one and when that one was next due. + +### Fixed + +- **The Scheduled Maintenance page states that there is one schedule**, in the header and again beside the + Save button, where the wording changes to "saving this replaces the one above" as soon as a schedule + exists. Previously the only hint was the header's passing mention of "one Windows scheduled task", which + reads as an internal detail rather than a limit that affects you. +- **The confirmation before saving now asks the question that matches what the button will do.** One text + served both cases and described only the first: "This creates a Windows scheduled task" appeared while + about to overwrite an existing one, so the dialog whose whole purpose is to stop an unwanted change hid + which change it was. Replacing now says so in those words and names the time the old schedule was next + due, so you can tell which one you are about to lose. + +### Changed + +- **Scheduled Maintenance keeps exactly one schedule, and that is now a stated decision rather than an + unstated limit.** Supporting several was considered and deliberately not done: the whole design rests on + touching exactly one Windows task by name, which is what keeps it from needing administrator rights and + what makes it incapable of disturbing anything else on the machine. + ## [1.109.3] - 2026-09-17 **A tab that failed to load could say nothing at all.** Every tab starts loading in the background the diff --git a/README.md b/README.md index 14b8ad87..c3412c67 100644 --- a/README.md +++ b/README.md @@ -853,6 +853,11 @@ Answers "what is actually using my space?" by listing the biggest files in one p - **Optional "only when I'm not using the PC"** condition, off by default - **The schedule you are about to save is spelled out in words**, conditions included, and updates as you change the settings +- **One schedule at a time, said out loud.** Saving replaces the schedule you already had + rather than adding a second, so the page says so — in the header and again beside the Save + button — and the confirmation names the time the old one was next due, so you can tell what + you are about to lose. One task by name is what keeps this feature from needing + administrator rights and from being able to touch anything else Windows schedules - **Windows' own count of skipped runs is shown** when it is not zero — the only signal Windows gives for a run its conditions blocked - Update or remove the schedule any time, each with a confirmation diff --git a/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs b/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs index 1ef571ae..68d2a384 100644 --- a/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs +++ b/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs @@ -198,6 +198,126 @@ await ps.DidNotReceive().RunAsync(Arg.Any(), Arg.Any()); } + /// + /// A view model over a runner that answers "a task IS registered", with the next run Windows reports. + /// + /// + /// Mirrors MaintenanceSchedulerServiceTests.StatusRow. The next run matters here rather than being + /// filler: the replace confirmation names it, so it is the thing that tells the user WHICH schedule they + /// are about to lose. + /// + private static async Task<(ScheduledMaintenanceViewModel vm, IPowerShellRunner ps)> NewScheduledVmAsync() + { + var row = new PSObject(); + row.Properties.Add(new PSNoteProperty("State", "Ready")); + row.Properties.Add(new PSNoteProperty("LastRunTime", new DateTime(2026, 6, 29, 3, 0, 0))); + row.Properties.Add(new PSNoteProperty("NextRunTime", new DateTime(2026, 6, 30, 3, 0, 0))); + row.Properties.Add(new PSNoteProperty("LastTaskResult", 0)); + + var ps = Substitute.For(); + ps.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(new Collection { row }); + var vm = new ScheduledMaintenanceViewModel(new MaintenanceSchedulerService(ps)); + await vm.InitializationComplete; + return (vm, ps); + } + + /// + /// The one-schedule rule is on screen before the user acts, and it says which of the two situations they + /// are in. + /// + /// + /// Nothing stated it. Saving does not add a second schedule, it overwrites the first, and someone who + /// wanted a weekly cleanup AND a monthly standby purge would have set the second and silently lost the + /// first (#1509). + /// + [Fact] + public void OneScheduleNote_WithNothingScheduled_SaysTheRuleWithoutWarning() + { + var (vm, _) = NewVm(); + + Assert.False(vm.IsScheduled); + Assert.Contains("one schedule at a time", vm.OneScheduleNote, StringComparison.Ordinal); + Assert.DoesNotContain("replaces", vm.OneScheduleNote, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task OneScheduleNote_WithOneScheduled_SaysSavingReplacesIt() + { + var (vm, _) = await NewScheduledVmAsync(); + + Assert.True(vm.IsScheduled); + Assert.Contains("one schedule at a time", vm.OneScheduleNote, StringComparison.Ordinal); + Assert.Contains("replaces the one above", vm.OneScheduleNote, StringComparison.Ordinal); + } + + /// + /// The save confirmation asks the question that matches what the click will do. + /// + /// + /// One text served both cases and described only the first: "This creates a Windows scheduled task" was + /// shown while about to overwrite an existing one, so the dialog whose whole job is to stop an unwanted + /// change concealed which change it was. Asserted through rather than by + /// calling a helper directly, because what matters is the text that reaches the user from the real command + /// path — and the surrounding tests' hand-rolled swap cannot read the wording at all. + /// + [Fact] + public async Task SaveSchedule_WithNothingScheduled_SaysItCreatesATask() + { + var (vm, _) = NewVm(); + + using var dialog = new DialogAnswer(confirm: false); + vm.SaveScheduleCommand.Execute(null); + if (vm.SaveScheduleCommand.ExecutionTask is { } running) await running; + + var shown = Assert.Single(dialog.Messages); + Assert.Contains("Schedule Maintenance — Confirm", shown, StringComparison.Ordinal); + Assert.Contains("creates a Windows scheduled task", shown, StringComparison.Ordinal); + // Even here it says the rule, so the limit is known before there is anything to lose. + Assert.Contains("one schedule at a time", shown, StringComparison.Ordinal); + } + + [Fact] + public async Task SaveSchedule_WithOneAlreadyScheduled_SaysItReplacesAndNamesTheNextRun() + { + var (vm, _) = await NewScheduledVmAsync(); + + using var dialog = new DialogAnswer(confirm: false); + vm.SaveScheduleCommand.Execute(null); + if (vm.SaveScheduleCommand.ExecutionTask is { } running) await running; + + var shown = Assert.Single(dialog.Messages); + Assert.Contains("Replace Schedule — Confirm", shown, StringComparison.Ordinal); + Assert.Contains("REPLACES", shown, StringComparison.Ordinal); + // The one detail available about the schedule being lost, and it comes from the same status read the + // card above displays, so the dialog and the card cannot disagree. + Assert.Contains("2026-06-30 03:00", shown, StringComparison.Ordinal); + Assert.DoesNotContain("creates a Windows scheduled task", shown, StringComparison.Ordinal); + } + + /// + /// Declining the replace confirmation leaves the existing schedule alone. + /// + /// + /// The counterpart to the wording tests: a dialog that says the right thing is worth nothing if No does + /// not mean no. Asserted on the runner, because "the task is unchanged" is only observable as "no register + /// script was sent". + /// + [Fact] + public async Task SaveSchedule_WhenTheUserDeclinesAReplacement_LeavesTheTaskAlone() + { + var (vm, ps) = await NewScheduledVmAsync(); + ps.ClearReceivedCalls(); // the constructor's status read is not what this asserts about + + using var dialog = new DialogAnswer(confirm: false); + vm.SaveScheduleCommand.Execute(null); + if (vm.SaveScheduleCommand.ExecutionTask is { } running) await running; + + Assert.Equal(1, dialog.Calls); + await ps.DidNotReceive().RunAsync(Arg.Any(), Arg.Any?>(), + Arg.Any()); + } + [Fact] public void MissedRunsWarning_IsEmptyWhenNoTaskIsRegistered() { diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index 30fce1b7..be8ae5cd 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -10,9 +10,9 @@ SysManager true NU1603;NU1701 - 1.109.3 - 1.109.3.0 - 1.109.3.0 + 1.109.4 + 1.109.4.0 + 1.109.4.0 SysManager SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup. https://github.com/laurentiu021/SystemManager diff --git a/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs b/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs index 047c303b..76860baf 100644 --- a/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs +++ b/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs @@ -119,6 +119,24 @@ private MaintenanceSchedule BuildSchedule() => /// public string PendingSummary => BuildSchedule().Summary; + /// + /// The one-schedule rule, stated where the user is about to act on it. + /// + /// + /// This tab registers a single Windows task at a fixed name, so Save does not add a second schedule — it + /// overwrites the first. Nothing said so. The header mentioned "one Windows scheduled task" as an + /// implementation detail, the Configure card offered an action and a time as though each Save were a new + /// entry, and the confirmation dialog said "This creates a Windows scheduled task" even when one already + /// existed. Someone who wanted a weekly cleanup AND a monthly standby purge would have set the second and + /// silently lost the first, with the tab then reporting the survivor as though nothing had gone (#1509). + /// Supporting more than one schedule was considered and deliberately not done: the whole design + /// rests on touching exactly one task by name, which is what makes it safe to register without admin and + /// impossible for it to disturb anything else Windows schedules. Saying so plainly is the fix. + /// + public string OneScheduleNote => IsScheduled + ? "SysManager keeps one schedule at a time, so saving this replaces the one above." + : "SysManager keeps one schedule at a time. You can change it or remove it whenever you like."; + [RelayCommand(CanExecute = nameof(NotBusy))] private async Task RefreshAsync() { @@ -154,14 +172,43 @@ private async Task LoadStatusAsync() RemoveScheduleCommand.NotifyCanExecuteChanged(); } + /// + /// What the Save confirmation asks. Two different questions, because Save does two different things. + /// + /// + /// The wording IS the behaviour for this gate. One text served both cases and it described only the first: + /// "This creates a Windows scheduled task" was shown while about to overwrite an existing one, so the + /// dialog that exists to stop an unwanted change actively concealed which change it was (#1509). + /// The replacement text names the existing task's next run, which is as specific as it can be: the + /// status read-back reports state and times, not which action or trigger Windows is holding. That is still + /// enough to tell the user WHICH schedule they are about to lose, and it comes from the same read the + /// card above displays, so the two cannot disagree. + /// + private string ConfirmSavePrompt(MaintenanceSchedule schedule) + { + if (!IsScheduled) + { + return $"Schedule \"{schedule.ActionLabel}\" to run automatically?\n\n{schedule.Summary}\n\n" + + "This creates a Windows scheduled task that launches SysManager in the background. " + + "SysManager keeps one schedule at a time, so saving again later replaces this one."; + } + + var existing = string.IsNullOrEmpty(NextRun) || NextRun == "—" + ? "the schedule already registered" + : $"the schedule already registered, whose next run was {NextRun}"; + + return $"Replace the maintenance schedule with \"{schedule.ActionLabel}\"?\n\n{schedule.Summary}\n\n" + + $"SysManager keeps one schedule at a time, so this REPLACES {existing}. Nothing else on your " + + "PC is changed, and you can remove the schedule at any time."; + } + [RelayCommand(CanExecute = nameof(NotBusy))] private async Task SaveScheduleAsync() { var schedule = BuildSchedule(); if (!DialogService.Instance.Confirm( - $"Schedule \"{schedule.ActionLabel}\" to run automatically?\n\n{schedule.Summary}\n\n" + - "This creates a Windows scheduled task that launches SysManager in the background.", - "Schedule Maintenance — Confirm")) + ConfirmSavePrompt(schedule), + IsScheduled ? "Replace Schedule — Confirm" : "Schedule Maintenance — Confirm")) return; IsBusy = true; @@ -204,7 +251,13 @@ private async Task RemoveScheduleAsync() finally { IsBusy = false; } } - partial void OnIsScheduledChanged(bool value) => RemoveScheduleCommand.NotifyCanExecuteChanged(); + partial void OnIsScheduledChanged(bool value) + { + RemoveScheduleCommand.NotifyCanExecuteChanged(); + // OneScheduleNote reads this, and the whole point of the note is that it changes from "you can + // change it whenever" to "saving replaces the one above" the moment a schedule exists. + OnPropertyChanged(nameof(OneScheduleNote)); + } protected override void Dispose(bool disposing) { diff --git a/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml b/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml index 2dc9fb89..b2036c2b 100644 --- a/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml +++ b/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml @@ -26,7 +26,10 @@ - + @@ -139,6 +142,14 @@ + + +