From 0d935d881d0f9a805fe0cf40aff54fb5f9dc0f7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:43:32 +0000 Subject: [PATCH 1/4] Initial plan From e27cc08e5fd901e41a4e863783e9064a0be7711d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:54:42 +0000 Subject: [PATCH 2/4] Add scheduled tasks feature: model, service, UI page, tests, and navigation - ScheduledTask model with Interval/Daily/Weekly schedule types - ScheduledTaskService with background timer evaluation and persistence - ScheduledTasks.razor page with create/edit/delete/toggle/run-now UI - 32 unit tests covering model, serialization, schedule logic, and service - Navigation link in sidebar and DI registration Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 2 + PolyPilot.Tests/ScheduledTaskTests.cs | 522 ++++++++++++++++++ PolyPilot.Tests/TestSetup.cs | 1 + .../Components/Layout/SessionSidebar.razor | 1 + .../Components/Pages/ScheduledTasks.razor | 381 +++++++++++++ .../Components/Pages/ScheduledTasks.razor.css | 419 ++++++++++++++ PolyPilot/MauiProgram.cs | 1 + PolyPilot/Models/ScheduledTask.cs | 175 ++++++ PolyPilot/Services/ScheduledTaskService.cs | 276 +++++++++ 9 files changed, 1778 insertions(+) create mode 100644 PolyPilot.Tests/ScheduledTaskTests.cs create mode 100644 PolyPilot/Components/Pages/ScheduledTasks.razor create mode 100644 PolyPilot/Components/Pages/ScheduledTasks.razor.css create mode 100644 PolyPilot/Models/ScheduledTask.cs create mode 100644 PolyPilot/Services/ScheduledTaskService.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 1ec6dde9..c2c8e8dc 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -92,6 +92,8 @@ + + diff --git a/PolyPilot.Tests/ScheduledTaskTests.cs b/PolyPilot.Tests/ScheduledTaskTests.cs new file mode 100644 index 00000000..7b562541 --- /dev/null +++ b/PolyPilot.Tests/ScheduledTaskTests.cs @@ -0,0 +1,522 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class ScheduledTaskTests +{ + private readonly StubChatDatabase _chatDb = new(); + private readonly StubServerManager _serverManager = new(); + private readonly StubWsBridgeClient _bridgeClient = new(); + private readonly StubDemoService _demoService = new(); + private readonly RepoManager _repoManager = new(); + private readonly IServiceProvider _serviceProvider; + + public ScheduledTaskTests() + { + var services = new ServiceCollection(); + _serviceProvider = services.BuildServiceProvider(); + } + + private CopilotService CreateCopilotService() => + new CopilotService(_chatDb, _serverManager, _bridgeClient, _repoManager, _serviceProvider, _demoService); + + private ScheduledTaskService CreateService() + { + return new ScheduledTaskService(CreateCopilotService()); + } + + // ── Model tests ───────────────────────────────────────────── + + [Fact] + public void ScheduledTask_DefaultValues() + { + var task = new ScheduledTask(); + + Assert.False(string.IsNullOrEmpty(task.Id)); + Assert.Equal("", task.Name); + Assert.Equal("", task.Prompt); + Assert.Null(task.SessionName); + Assert.Equal(ScheduleType.Daily, task.Schedule); + Assert.Equal(60, task.IntervalMinutes); + Assert.Equal("09:00", task.TimeOfDay); + Assert.Equal(new List { 1, 2, 3, 4, 5 }, task.DaysOfWeek); + Assert.True(task.IsEnabled); + Assert.Null(task.LastRunAt); + Assert.Empty(task.RecentRuns); + } + + [Fact] + public void ScheduledTask_JsonRoundTrip() + { + var original = new ScheduledTask + { + Name = "Daily Standup", + Prompt = "Give me a summary of yesterday's changes", + Schedule = ScheduleType.Daily, + TimeOfDay = "10:30", + IsEnabled = true, + SessionName = "my-session", + Model = "claude-opus-4.6" + }; + + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal(original.Id, deserialized!.Id); + Assert.Equal("Daily Standup", deserialized.Name); + Assert.Equal("Give me a summary of yesterday's changes", deserialized.Prompt); + Assert.Equal(ScheduleType.Daily, deserialized.Schedule); + Assert.Equal("10:30", deserialized.TimeOfDay); + Assert.True(deserialized.IsEnabled); + Assert.Equal("my-session", deserialized.SessionName); + Assert.Equal("claude-opus-4.6", deserialized.Model); + } + + [Fact] + public void ScheduledTask_JsonRoundTrip_List() + { + var tasks = new List + { + new() { Name = "Task 1", Prompt = "Prompt 1", Schedule = ScheduleType.Interval, IntervalMinutes = 30 }, + new() { Name = "Task 2", Prompt = "Prompt 2", Schedule = ScheduleType.Weekly, DaysOfWeek = new() { 1, 3, 5 } } + }; + + var json = JsonSerializer.Serialize(tasks, new JsonSerializerOptions { WriteIndented = true }); + var deserialized = JsonSerializer.Deserialize>(json); + + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized!.Count); + Assert.Equal("Task 1", deserialized[0].Name); + Assert.Equal(30, deserialized[0].IntervalMinutes); + Assert.Equal("Task 2", deserialized[1].Name); + Assert.Equal(new List { 1, 3, 5 }, deserialized[1].DaysOfWeek); + } + + // ── Schedule description ──────────────────────────────────── + + [Fact] + public void ScheduleDescription_Interval() + { + var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 30 }; + Assert.Equal("Every 30 minutes", task.ScheduleDescription); + } + + [Fact] + public void ScheduleDescription_Interval_Singular() + { + var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 1 }; + Assert.Equal("Every 1 minute", task.ScheduleDescription); + } + + [Fact] + public void ScheduleDescription_Daily() + { + var task = new ScheduledTask { Schedule = ScheduleType.Daily, TimeOfDay = "14:00" }; + Assert.Equal("Daily at 14:00", task.ScheduleDescription); + } + + [Fact] + public void ScheduleDescription_Weekly() + { + var task = new ScheduledTask { Schedule = ScheduleType.Weekly, TimeOfDay = "09:00", DaysOfWeek = new() { 1, 3, 5 } }; + Assert.Equal("Weekly (Mon, Wed, Fri) at 09:00", task.ScheduleDescription); + } + + // ── ParseTimeOfDay ────────────────────────────────────────── + + [Theory] + [InlineData("09:00", 9, 0)] + [InlineData("14:30", 14, 30)] + [InlineData("00:00", 0, 0)] + [InlineData("23:59", 23, 59)] + public void ParseTimeOfDay_ValidInputs(string input, int expectedHours, int expectedMinutes) + { + var task = new ScheduledTask { TimeOfDay = input }; + var (h, m) = task.ParseTimeOfDay(); + Assert.Equal(expectedHours, h); + Assert.Equal(expectedMinutes, m); + } + + [Fact] + public void ParseTimeOfDay_InvalidInput_ReturnsDefault() + { + var task = new ScheduledTask { TimeOfDay = "not-a-time" }; + var (h, m) = task.ParseTimeOfDay(); + Assert.Equal(9, h); + Assert.Equal(0, m); + } + + // ── IsDue ─────────────────────────────────────────────────── + + [Fact] + public void IsDue_DisabledTask_ReturnsFalse() + { + var task = new ScheduledTask + { + Schedule = ScheduleType.Interval, + IntervalMinutes = 1, + IsEnabled = false + }; + Assert.False(task.IsDue(DateTime.UtcNow)); + } + + [Fact] + public void IsDue_IntervalTask_NeverRun_ReturnsTrue() + { + var task = new ScheduledTask + { + Schedule = ScheduleType.Interval, + IntervalMinutes = 60, + IsEnabled = true, + LastRunAt = null + }; + Assert.True(task.IsDue(DateTime.UtcNow)); + } + + [Fact] + public void IsDue_IntervalTask_RecentlyRun_ReturnsFalse() + { + var now = DateTime.UtcNow; + var task = new ScheduledTask + { + Schedule = ScheduleType.Interval, + IntervalMinutes = 60, + IsEnabled = true, + LastRunAt = now.AddMinutes(-10) // ran 10 min ago, interval is 60 + }; + Assert.False(task.IsDue(now)); + } + + [Fact] + public void IsDue_IntervalTask_PastDue_ReturnsTrue() + { + var now = DateTime.UtcNow; + var task = new ScheduledTask + { + Schedule = ScheduleType.Interval, + IntervalMinutes = 60, + IsEnabled = true, + LastRunAt = now.AddMinutes(-65) // ran 65 min ago, interval is 60 + }; + Assert.True(task.IsDue(now)); + } + + // ── RecordRun ─────────────────────────────────────────────── + + [Fact] + public void RecordRun_AddsRunAndUpdatesLastRunAt() + { + var task = new ScheduledTask { Name = "test" }; + var run = new ScheduledTaskRun { StartedAt = DateTime.UtcNow, Success = true }; + + task.RecordRun(run); + + Assert.Single(task.RecentRuns); + Assert.Equal(run.StartedAt, task.LastRunAt); + } + + [Fact] + public void RecordRun_TrimsToTenEntries() + { + var task = new ScheduledTask { Name = "test" }; + for (int i = 0; i < 15; i++) + { + task.RecordRun(new ScheduledTaskRun + { + StartedAt = DateTime.UtcNow.AddMinutes(i), + Success = true + }); + } + + Assert.Equal(10, task.RecentRuns.Count); + } + + // ── GetNextRunTimeUtc ─────────────────────────────────────── + + [Fact] + public void GetNextRunTimeUtc_IntervalZero_ReturnsNull() + { + var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 0 }; + Assert.Null(task.GetNextRunTimeUtc(DateTime.UtcNow)); + } + + [Fact] + public void GetNextRunTimeUtc_WeeklyNoDays_ReturnsNull() + { + var task = new ScheduledTask { Schedule = ScheduleType.Weekly, DaysOfWeek = new() }; + Assert.Null(task.GetNextRunTimeUtc(DateTime.UtcNow)); + } + + [Fact] + public void GetNextRunTimeUtc_IntervalNeverRun_ReturnsNow() + { + var now = DateTime.UtcNow; + var task = new ScheduledTask + { + Schedule = ScheduleType.Interval, + IntervalMinutes = 60, + LastRunAt = null + }; + var next = task.GetNextRunTimeUtc(now); + Assert.NotNull(next); + Assert.Equal(now, next!.Value); + } + + [Fact] + public void GetNextRunTimeUtc_IntervalAfterRun_ReturnsLastPlusInterval() + { + var now = DateTime.UtcNow; + var lastRun = now.AddMinutes(-30); + var task = new ScheduledTask + { + Schedule = ScheduleType.Interval, + IntervalMinutes = 60, + LastRunAt = lastRun + }; + var next = task.GetNextRunTimeUtc(now); + Assert.NotNull(next); + Assert.Equal(lastRun.AddMinutes(60), next!.Value); + } + + // ── ScheduleType enum ─────────────────────────────────────── + + [Fact] + public void ScheduleType_HasExpectedValues() + { + Assert.Equal(0, (int)ScheduleType.Interval); + Assert.Equal(1, (int)ScheduleType.Daily); + Assert.Equal(2, (int)ScheduleType.Weekly); + } + + // ── Service persistence tests ─────────────────────────────── + + [Fact] + public void Service_SaveAndLoad_RoundTrips() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var svc = CreateService(); + svc.AddTask(new ScheduledTask { Name = "Test Task", Prompt = "Do something" }); + + // Create a new service instance to verify it loads from disk + var svc2 = CreateService(); + var loaded = svc2.GetTasks(); + + Assert.Single(loaded); + Assert.Equal("Test Task", loaded[0].Name); + Assert.Equal("Do something", loaded[0].Prompt); + } + finally + { + try { File.Delete(tempFile); } catch { } + // Reset to test path + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public void Service_DeleteTask_RemovesFromList() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var svc = CreateService(); + var task = new ScheduledTask { Name = "To Delete", Prompt = "test" }; + svc.AddTask(task); + Assert.Single(svc.GetTasks()); + + var result = svc.DeleteTask(task.Id); + Assert.True(result); + Assert.Empty(svc.GetTasks()); + } + finally + { + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public void Service_SetEnabled_TogglesState() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var svc = CreateService(); + var task = new ScheduledTask { Name = "Toggle", Prompt = "test", IsEnabled = true }; + svc.AddTask(task); + + svc.SetEnabled(task.Id, false); + Assert.False(svc.GetTask(task.Id)!.IsEnabled); + + svc.SetEnabled(task.Id, true); + Assert.True(svc.GetTask(task.Id)!.IsEnabled); + } + finally + { + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public void Service_UpdateTask_ModifiesExistingTask() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var svc = CreateService(); + var task = new ScheduledTask { Name = "Original", Prompt = "original" }; + svc.AddTask(task); + + task.Name = "Updated"; + task.Prompt = "updated"; + svc.UpdateTask(task); + + var loaded = svc.GetTask(task.Id); + Assert.NotNull(loaded); + Assert.Equal("Updated", loaded!.Name); + Assert.Equal("updated", loaded.Prompt); + } + finally + { + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public async Task Service_EvaluateTasksAsync_ExecutesDueTasks() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var copilot = CreateCopilotService(); + var svc = new ScheduledTaskService(copilot); + // Initialize CopilotService in demo mode so it can accept prompts + await copilot.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await copilot.CreateSessionAsync("test-session"); + + var task = new ScheduledTask + { + Name = "Due Task", + Prompt = "Hello", + Schedule = ScheduleType.Interval, + IntervalMinutes = 1, + IsEnabled = true, + LastRunAt = DateTime.UtcNow.AddMinutes(-5), + SessionName = "test-session" + }; + svc.AddTask(task); + + await svc.EvaluateTasksAsync(); + + var updated = svc.GetTask(task.Id); + Assert.NotNull(updated); + Assert.Single(updated!.RecentRuns); + Assert.True(updated.RecentRuns[0].Success); + } + finally + { + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public async Task Service_ExecuteTask_RecordsErrorWhenNotInitialized() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var svc = CreateService(); + // Do NOT initialize CopilotService + var task = new ScheduledTask { Name = "Fail", Prompt = "test" }; + svc.AddTask(task); + + await svc.ExecuteTaskAsync(task, DateTime.UtcNow); + + var updated = svc.GetTask(task.Id); + Assert.NotNull(updated); + Assert.Single(updated!.RecentRuns); + Assert.False(updated.RecentRuns[0].Success); + Assert.Contains("not initialized", updated.RecentRuns[0].Error); + } + finally + { + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public void Service_EvaluationIntervalSeconds_IsReasonable() + { + // Evaluation interval should be frequent enough to be useful but not too aggressive + Assert.InRange(ScheduledTaskService.EvaluationIntervalSeconds, 10, 120); + } + + [Fact] + public void Service_LoadTasks_HandlesCorruptFile() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + // Write corrupt JSON + Directory.CreateDirectory(Path.GetDirectoryName(tempFile)!); + File.WriteAllText(tempFile, "{{not json}}"); + + var svc = CreateService(); + Assert.Empty(svc.GetTasks()); + } + finally + { + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } + + [Fact] + public void Service_LoadTasks_HandlesNonexistentFile() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + try + { + var svc = CreateService(); + Assert.Empty(svc.GetTasks()); + } + finally + { + ScheduledTaskService.SetTasksFilePathForTesting( + Path.Combine(TestSetup.TestBaseDir, "scheduled-tasks.json")); + } + } +} diff --git a/PolyPilot.Tests/TestSetup.cs b/PolyPilot.Tests/TestSetup.cs index 2a65dfca..fbc44f1f 100644 --- a/PolyPilot.Tests/TestSetup.cs +++ b/PolyPilot.Tests/TestSetup.cs @@ -29,5 +29,6 @@ internal static void Initialize() RepoManager.SetBaseDirForTesting(TestBaseDir); AuditLogService.SetLogDirForTesting(Path.Combine(TestBaseDir, "audit_logs")); PromptLibraryService.SetUserPromptsDirForTesting(Path.Combine(TestBaseDir, "prompts")); + ScheduledTaskService.SetTasksFilePathForTesting(Path.Combine(TestBaseDir, "scheduled-tasks.json")); } } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 877bd261..aa1ef7de 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -56,6 +56,7 @@ else +
diff --git a/PolyPilot/Components/Pages/ScheduledTasks.razor b/PolyPilot/Components/Pages/ScheduledTasks.razor new file mode 100644 index 00000000..a3324ade --- /dev/null +++ b/PolyPilot/Components/Pages/ScheduledTasks.razor @@ -0,0 +1,381 @@ +@page "/scheduled-tasks" +@using PolyPilot.Services +@using PolyPilot.Models +@inject CopilotService CopilotService +@inject ScheduledTaskService TaskService +@inject NavigationManager Nav +@implements IDisposable + +
+
+
+

⏰ Scheduled Tasks

+ +
+

Create recurring tasks that automatically send prompts on a schedule — ideal for daily stand-ups, periodic reviews, and repetitive executions.

+
+ +
+ @if (showForm) + { +
+
+

@(editingTask != null ? "Edit Task" : "New Scheduled Task")

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (formSchedule == ScheduleType.Interval) + { +
+ + +
+ } + else + { +
+ + +
+ } + + @if (formSchedule == ScheduleType.Weekly) + { +
+ +
+ @foreach (var (idx, name) in dayNames) + { + + } +
+
+ } + +
+ + +
+ + @if (string.IsNullOrEmpty(formSessionName)) + { +
+ + +
+ } + + @if (!string.IsNullOrEmpty(formError)) + { +
@formError
+ } + +
+ + +
+
+ } + + @if (!tasks.Any() && !showForm) + { +
+
+

No scheduled tasks yet

+

Create a recurring task to automatically send prompts on a schedule.

+ +
+ } + else if (tasks.Any()) + { +
+ @foreach (var task in tasks) + { +
+
+
+ @task.Name + @task.ScheduleDescription +
+
+ + + + +
+
+
+
@TruncatePrompt(task.Prompt, 120)
+ @if (!string.IsNullOrEmpty(task.SessionName)) + { + Session: @task.SessionName + } + else + { + New session each run + } +
+ @if (task.RecentRuns.Any()) + { + + } + else if (task.IsEnabled) + { + + } +
+ } +
+ } +
+
+ +@code { + private List tasks = new(); + private bool showForm; + private ScheduledTask? editingTask; + + // Form fields + private string formName = ""; + private string formPrompt = ""; + private ScheduleType formSchedule = ScheduleType.Daily; + private int formIntervalMinutes = 60; + private string formTimeOfDay = "09:00"; + private List formDays = new() { 1, 2, 3, 4, 5 }; + private string formSessionName = ""; + private string formModel = ""; + private string? formError; + + private static readonly (int idx, string name)[] dayNames = new[] + { + (0, "Sun"), (1, "Mon"), (2, "Tue"), (3, "Wed"), (4, "Thu"), (5, "Fri"), (6, "Sat") + }; + + protected override void OnInitialized() + { + tasks = TaskService.GetTasks().ToList(); + TaskService.OnTasksChanged += RefreshTasks; + } + + private void RefreshTasks() + { + tasks = TaskService.GetTasks().ToList(); + InvokeAsync(StateHasChanged); + } + + private void ShowCreateForm() + { + editingTask = null; + formName = ""; + formPrompt = ""; + formSchedule = ScheduleType.Daily; + formIntervalMinutes = 60; + formTimeOfDay = "09:00"; + formDays = new List { 1, 2, 3, 4, 5 }; + formSessionName = ""; + formModel = ""; + formError = null; + showForm = true; + } + + private void EditTask(ScheduledTask task) + { + editingTask = task; + formName = task.Name; + formPrompt = task.Prompt; + formSchedule = task.Schedule; + formIntervalMinutes = task.IntervalMinutes; + formTimeOfDay = task.TimeOfDay; + formDays = task.DaysOfWeek.ToList(); + formSessionName = task.SessionName ?? ""; + formModel = task.Model ?? ""; + formError = null; + showForm = true; + } + + private void CancelForm() + { + showForm = false; + editingTask = null; + formError = null; + } + + private void SaveTask() + { + formError = null; + + if (string.IsNullOrWhiteSpace(formName)) + { + formError = "Name is required."; + return; + } + if (string.IsNullOrWhiteSpace(formPrompt)) + { + formError = "Prompt is required."; + return; + } + if (formSchedule == ScheduleType.Interval && formIntervalMinutes < 1) + { + formError = "Interval must be at least 1 minute."; + return; + } + if (formSchedule == ScheduleType.Weekly && !formDays.Any()) + { + formError = "Select at least one day."; + return; + } + + if (editingTask != null) + { + editingTask.Name = formName.Trim(); + editingTask.Prompt = formPrompt.Trim(); + editingTask.Schedule = formSchedule; + editingTask.IntervalMinutes = formIntervalMinutes; + editingTask.TimeOfDay = formTimeOfDay; + editingTask.DaysOfWeek = formDays.ToList(); + editingTask.SessionName = string.IsNullOrWhiteSpace(formSessionName) ? null : formSessionName; + editingTask.Model = string.IsNullOrWhiteSpace(formModel) ? null : formModel; + TaskService.UpdateTask(editingTask); + } + else + { + var task = new ScheduledTask + { + Name = formName.Trim(), + Prompt = formPrompt.Trim(), + Schedule = formSchedule, + IntervalMinutes = formIntervalMinutes, + TimeOfDay = formTimeOfDay, + DaysOfWeek = formDays.ToList(), + SessionName = string.IsNullOrWhiteSpace(formSessionName) ? null : formSessionName, + Model = string.IsNullOrWhiteSpace(formModel) ? null : formModel + }; + TaskService.AddTask(task); + } + + showForm = false; + editingTask = null; + tasks = TaskService.GetTasks().ToList(); + } + + private void DeleteTask(ScheduledTask task) + { + TaskService.DeleteTask(task.Id); + tasks = TaskService.GetTasks().ToList(); + } + + private void ToggleEnabled(ScheduledTask task, ChangeEventArgs e) + { + var enabled = (bool)(e.Value ?? false); + TaskService.SetEnabled(task.Id, enabled); + tasks = TaskService.GetTasks().ToList(); + } + + private void ToggleDay(int day) + { + if (formDays.Contains(day)) + formDays.Remove(day); + else + formDays.Add(day); + } + + private async Task RunNow(ScheduledTask task) + { + await TaskService.ExecuteTaskAsync(task, DateTime.UtcNow); + tasks = TaskService.GetTasks().ToList(); + } + + private static string TruncatePrompt(string prompt, int maxLen) + { + if (string.IsNullOrEmpty(prompt)) return ""; + return prompt.Length <= maxLen ? prompt : prompt[..maxLen] + "…"; + } + + private static string FormatTimeAgo(DateTime utcTime) + { + var now = DateTime.UtcNow; + if (utcTime > now) + { + var diff = utcTime - now; + if (diff.TotalMinutes < 1) return "in <1 min"; + if (diff.TotalMinutes < 60) return $"in {(int)diff.TotalMinutes} min"; + if (diff.TotalHours < 24) return $"in {(int)diff.TotalHours}h {diff.Minutes}m"; + return utcTime.ToLocalTime().ToString("MMM d, h:mm tt"); + } + else + { + var diff = now - utcTime; + if (diff.TotalMinutes < 1) return "<1 min ago"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} min ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + return utcTime.ToLocalTime().ToString("MMM d, h:mm tt"); + } + } + + public void Dispose() + { + TaskService.OnTasksChanged -= RefreshTasks; + } +} diff --git a/PolyPilot/Components/Pages/ScheduledTasks.razor.css b/PolyPilot/Components/Pages/ScheduledTasks.razor.css new file mode 100644 index 00000000..1fa4bd46 --- /dev/null +++ b/PolyPilot/Components/Pages/ScheduledTasks.razor.css @@ -0,0 +1,419 @@ +.scheduled-tasks-page { + padding: 1.5rem; + color: var(--text-primary); + background: var(--bg-primary); + height: 100%; + overflow-y: auto; + max-width: 800px; + box-sizing: border-box; +} + +.scheduled-tasks-header { + margin-bottom: 1.5rem; +} + +.scheduled-tasks-header h2 { + margin: 0; + font-size: var(--type-title2); +} + +.header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.header-subtitle { + margin: 0.5rem 0 0; + color: var(--text-dim); + font-size: var(--type-callout); +} + +.add-task-btn { + padding: 0.5rem 1rem; + background: var(--accent-primary, #7c5cfc); + color: #fff; + border: none; + border-radius: 6px; + font-size: var(--type-callout); + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.add-task-btn:hover { + opacity: 0.9; +} + +/* ── Form ─────────────────────────────────────────── */ + +.task-form-card { + background: var(--bg-secondary, var(--bg-primary)); + border: 1px solid var(--control-border); + border-radius: 10px; + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.form-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.form-header h3 { + margin: 0; + font-size: var(--type-title3); +} + +.form-close-btn { + background: none; + border: none; + color: var(--text-dim); + font-size: 1.2rem; + cursor: pointer; + padding: 0.2rem 0.4rem; +} + +.form-group { + margin-bottom: 0.9rem; +} + +.form-group label { + display: block; + margin-bottom: 0.3rem; + font-size: var(--type-callout); + color: var(--text-dim); + font-weight: 500; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid var(--control-border); + border-radius: 6px; + background: var(--bg-input, var(--bg-primary)); + color: var(--text-primary); + font-size: var(--type-body); + box-sizing: border-box; + outline: none; + transition: border-color 0.2s; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + border-color: var(--accent-primary, #7c5cfc); +} + +.short-input { + max-width: 200px; +} + +.form-textarea { + resize: vertical; + font-family: inherit; + min-height: 80px; +} + +.form-error { + color: #e74c3c; + font-size: var(--type-callout); + margin-bottom: 0.75rem; + padding: 0.4rem 0.6rem; + background: rgba(231, 76, 60, 0.08); + border-radius: 6px; +} + +.form-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.btn-primary { + padding: 0.5rem 1rem; + background: var(--accent-primary, #7c5cfc); + color: #fff; + border: none; + border-radius: 6px; + font-size: var(--type-callout); + cursor: pointer; + transition: opacity 0.15s; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-secondary { + padding: 0.5rem 1rem; + background: transparent; + color: var(--text-dim); + border: 1px solid var(--control-border); + border-radius: 6px; + font-size: var(--type-callout); + cursor: pointer; + transition: background 0.15s; +} + +.btn-secondary:hover { + background: var(--hover-bg); +} + +/* ── Day picker ───────────────────────────────────── */ + +.day-picker { + display: flex; + gap: 0.35rem; +} + +.day-btn { + padding: 0.35rem 0.55rem; + border: 1px solid var(--control-border); + background: transparent; + color: var(--text-dim); + border-radius: 6px; + font-size: var(--type-callout); + cursor: pointer; + transition: all 0.15s; +} + +.day-btn.selected { + background: var(--accent-primary, #7c5cfc); + color: #fff; + border-color: var(--accent-primary, #7c5cfc); +} + +.day-btn:hover:not(.selected) { + background: var(--hover-bg); +} + +/* ── Task list ────────────────────────────────────── */ + +.task-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.task-card { + background: var(--bg-secondary, var(--bg-primary)); + border: 1px solid var(--control-border); + border-radius: 10px; + padding: 1rem; + transition: border-color 0.15s; +} + +.task-card:hover { + border-color: var(--accent-primary, #7c5cfc); +} + +.task-card.disabled { + opacity: 0.55; +} + +.task-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.task-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.task-name { + font-weight: 600; + font-size: var(--type-body); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-schedule { + font-size: var(--type-callout); + color: var(--text-dim); + margin-top: 0.15rem; +} + +.task-actions { + display: flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.icon-btn { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0.3rem; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} + +.icon-btn:hover { + color: var(--text-primary); + background: var(--hover-bg); +} + +.icon-btn.danger:hover { + color: #e74c3c; +} + +/* ── Toggle switch ────────────────────────────────── */ + +.toggle-switch { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--control-border); + border-radius: 10px; + transition: background 0.2s; +} + +.toggle-slider::before { + content: ""; + position: absolute; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--accent-primary, #7c5cfc); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(16px); +} + +/* ── Task card body/footer ────────────────────────── */ + +.task-card-body { + margin-top: 0.6rem; + padding-top: 0.6rem; + border-top: 1px solid var(--control-border); +} + +.task-prompt-preview { + font-size: var(--type-callout); + color: var(--text-dim); + line-height: 1.4; + word-break: break-word; +} + +.task-tag { + display: inline-block; + margin-top: 0.4rem; + padding: 0.15rem 0.5rem; + font-size: 0.75rem; + background: var(--hover-bg, rgba(124, 92, 252, 0.1)); + border-radius: 4px; + color: var(--text-dim); +} + +.task-card-footer { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--control-border); + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-dim); +} + +.last-run, +.next-run { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.run-status.success { + color: #2ecc71; +} + +.run-status.error { + color: #e74c3c; +} + +/* ── Empty state ──────────────────────────────────── */ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 3rem 1rem; +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.empty-title { + font-size: var(--type-title3); + margin: 0 0 0.5rem; +} + +.empty-desc { + color: var(--text-dim); + font-size: var(--type-callout); + margin: 0 0 1.5rem; + max-width: 360px; +} + +/* ── Responsive ───────────────────────────────────── */ + +@media (max-width: 600px) { + .scheduled-tasks-page { + padding: 1rem; + } + .header-row { + flex-direction: column; + align-items: stretch; + } + .task-card-header { + flex-direction: column; + align-items: flex-start; + } + .task-actions { + margin-top: 0.5rem; + } +} diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index b313f32a..53b91ab7 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -116,6 +116,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(SpeechToText.Default); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/PolyPilot/Models/ScheduledTask.cs b/PolyPilot/Models/ScheduledTask.cs new file mode 100644 index 00000000..57ec6eab --- /dev/null +++ b/PolyPilot/Models/ScheduledTask.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PolyPilot.Models; + +/// +/// Defines the recurrence type for a scheduled task. +/// +public enum ScheduleType +{ + /// Run every N minutes. + Interval, + /// Run once daily at a specific time. + Daily, + /// Run on specific days of the week at a specific time. + Weekly +} + +/// +/// A single execution log entry for a scheduled task. +/// +public class ScheduledTaskRun +{ + public DateTime StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? SessionName { get; set; } + public bool Success { get; set; } + public string? Error { get; set; } +} + +/// +/// A recurring task definition — prompt, schedule, and execution state. +/// Persisted to ~/.polypilot/scheduled-tasks.json. +/// +public class ScheduledTask +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = ""; + public string Prompt { get; set; } = ""; + + /// + /// Target an existing session by name. If null, a new session is created for each run. + /// + public string? SessionName { get; set; } + + /// Model to use when creating a new session. Ignored when SessionName is set. + public string? Model { get; set; } + + /// Working directory for newly created sessions. + public string? WorkingDirectory { get; set; } + + public ScheduleType Schedule { get; set; } = ScheduleType.Daily; + + /// Interval in minutes — used when Schedule == Interval. + public int IntervalMinutes { get; set; } = 60; + + /// Time of day (local) — used when Schedule is Daily or Weekly. + public string TimeOfDay { get; set; } = "09:00"; + + /// Days of week — used when Schedule == Weekly. 0=Sunday..6=Saturday. + public List DaysOfWeek { get; set; } = new() { 1, 2, 3, 4, 5 }; // weekdays + + public bool IsEnabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastRunAt { get; set; } + + /// Recent execution history (kept to last 10 runs). + public List RecentRuns { get; set; } = new(); + + // ── Schedule calculation ────────────────────────────────────────── + + /// + /// Parses the TimeOfDay string ("HH:mm") into hours and minutes. + /// Returns (9, 0) as default if parsing fails. + /// + internal (int hours, int minutes) ParseTimeOfDay() + { + if (TimeSpan.TryParse(TimeOfDay, out var ts)) + return (ts.Hours, ts.Minutes); + return (9, 0); + } + + /// + /// Calculates the next run time based on the schedule and the last run time. + /// Returns null if the task cannot be scheduled (e.g., Weekly with no days selected). + /// + public DateTime? GetNextRunTimeUtc(DateTime now) + { + switch (Schedule) + { + case ScheduleType.Interval: + if (IntervalMinutes <= 0) return null; + if (LastRunAt == null) return now; // run immediately + var next = LastRunAt.Value.AddMinutes(IntervalMinutes); + return next <= now ? now : next; + + case ScheduleType.Daily: + { + var (h, m) = ParseTimeOfDay(); + var todayLocal = now.ToLocalTime().Date.AddHours(h).AddMinutes(m); + var todayUtc = todayLocal.ToUniversalTime(); + if (LastRunAt == null) return todayUtc <= now ? todayUtc : todayUtc; + if (todayUtc > now && (LastRunAt == null || LastRunAt.Value.Date < now.ToLocalTime().Date)) + return todayUtc; + // Next day + return todayLocal.AddDays(1).ToUniversalTime(); + } + + case ScheduleType.Weekly: + { + if (DaysOfWeek.Count == 0) return null; + var (h, m) = ParseTimeOfDay(); + var localNow = now.ToLocalTime(); + // Look up to 8 days ahead to find the next matching day + for (int i = 0; i <= 7; i++) + { + var candidate = localNow.Date.AddDays(i).AddHours(h).AddMinutes(m); + var candidateUtc = candidate.ToUniversalTime(); + if (candidateUtc <= now && i == 0) continue; // today's slot already passed + var dow = (int)candidate.DayOfWeek; + if (DaysOfWeek.Contains(dow)) + { + // Ensure we haven't already run at this slot + if (LastRunAt != null && LastRunAt.Value >= candidateUtc) continue; + return candidateUtc; + } + } + return null; + } + + default: + return null; + } + } + + /// Returns true if the task is due to run now. + public bool IsDue(DateTime utcNow) + { + if (!IsEnabled) return false; + var next = GetNextRunTimeUtc(utcNow); + return next != null && next.Value <= utcNow; + } + + /// Adds a run entry and trims history to 10 entries. + public void RecordRun(ScheduledTaskRun run) + { + RecentRuns.Add(run); + if (RecentRuns.Count > 10) + RecentRuns.RemoveRange(0, RecentRuns.Count - 10); + LastRunAt = run.StartedAt; + } + + /// Human-readable schedule description for the UI. + [JsonIgnore] + public string ScheduleDescription + { + get + { + return Schedule switch + { + ScheduleType.Interval => $"Every {IntervalMinutes} minute{(IntervalMinutes != 1 ? "s" : "")}", + ScheduleType.Daily => $"Daily at {TimeOfDay}", + ScheduleType.Weekly => $"Weekly ({FormatDays()}) at {TimeOfDay}", + _ => "Unknown" + }; + } + } + + private string FormatDays() + { + var dayNames = new[] { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; + var sorted = DaysOfWeek.Where(d => d >= 0 && d <= 6).OrderBy(d => d); + return string.Join(", ", sorted.Select(d => dayNames[d])); + } +} diff --git a/PolyPilot/Services/ScheduledTaskService.cs b/PolyPilot/Services/ScheduledTaskService.cs new file mode 100644 index 00000000..a3b17fa0 --- /dev/null +++ b/PolyPilot/Services/ScheduledTaskService.cs @@ -0,0 +1,276 @@ +using System.Text.Json; +using PolyPilot.Models; + +namespace PolyPilot.Services; + +/// +/// Manages scheduled (recurring) tasks — persistence, background evaluation, and execution. +/// Tasks are stored in ~/.polypilot/scheduled-tasks.json and evaluated every 30 seconds. +/// When a task is due, it sends the configured prompt to the target session (or creates a new one). +/// +public class ScheduledTaskService : IDisposable +{ + private static string? _tasksFilePath; + private static string TasksFilePath => _tasksFilePath ??= Path.Combine(GetPolyPilotDir(), "scheduled-tasks.json"); + + /// Override file path for tests to prevent writing to real ~/.polypilot/. + internal static void SetTasksFilePathForTesting(string path) => _tasksFilePath = path; + + private readonly CopilotService _copilotService; + private readonly List _tasks = new(); + private readonly object _lock = new(); + private Timer? _evaluationTimer; + private bool _disposed; + + /// Raised when any task list or state change occurs (for UI refresh). + public event Action? OnTasksChanged; + + /// Interval between schedule evaluations. + internal const int EvaluationIntervalSeconds = 30; + + public ScheduledTaskService(CopilotService copilotService) + { + _copilotService = copilotService; + LoadTasks(); + Start(); // Auto-start the evaluation timer + } + + /// Start the background evaluation timer. + public void Start() + { + _evaluationTimer?.Dispose(); + _evaluationTimer = new Timer( + _ => _ = EvaluateTasksAsync(), + null, + TimeSpan.FromSeconds(EvaluationIntervalSeconds), + TimeSpan.FromSeconds(EvaluationIntervalSeconds)); + } + + /// Stop the background evaluation timer. + public void Stop() + { + _evaluationTimer?.Dispose(); + _evaluationTimer = null; + } + + // ── CRUD ────────────────────────────────────────────────────────── + + public IReadOnlyList GetTasks() + { + lock (_lock) return _tasks.ToList(); + } + + public ScheduledTask? GetTask(string id) + { + lock (_lock) return _tasks.FirstOrDefault(t => t.Id == id); + } + + public void AddTask(ScheduledTask task) + { + lock (_lock) _tasks.Add(task); + SaveTasks(); + OnTasksChanged?.Invoke(); + } + + public void UpdateTask(ScheduledTask task) + { + lock (_lock) + { + var idx = _tasks.FindIndex(t => t.Id == task.Id); + if (idx >= 0) _tasks[idx] = task; + } + SaveTasks(); + OnTasksChanged?.Invoke(); + } + + public bool DeleteTask(string id) + { + bool removed; + lock (_lock) removed = _tasks.RemoveAll(t => t.Id == id) > 0; + if (removed) + { + SaveTasks(); + OnTasksChanged?.Invoke(); + } + return removed; + } + + public void SetEnabled(string id, bool enabled) + { + lock (_lock) + { + var task = _tasks.FirstOrDefault(t => t.Id == id); + if (task != null) task.IsEnabled = enabled; + } + SaveTasks(); + OnTasksChanged?.Invoke(); + } + + // ── Evaluation ─────────────────────────────────────────────────── + + /// + /// Evaluate all tasks and execute any that are due. + /// Called by the background timer every 30 seconds. + /// + internal async Task EvaluateTasksAsync() + { + List dueTasks; + var now = DateTime.UtcNow; + + lock (_lock) + { + dueTasks = _tasks.Where(t => t.IsDue(now)).ToList(); + } + + foreach (var task in dueTasks) + { + await ExecuteTaskAsync(task, now); + } + } + + /// Execute a single scheduled task. + internal async Task ExecuteTaskAsync(ScheduledTask task, DateTime utcNow) + { + var run = new ScheduledTaskRun { StartedAt = utcNow }; + + try + { + if (!_copilotService.IsInitialized) + { + run.Error = "CopilotService not initialized"; + run.Success = false; + RecordRunAndSave(task, run); + return; + } + + string sessionName; + + if (!string.IsNullOrEmpty(task.SessionName)) + { + // Use existing session + sessionName = task.SessionName; + var sessions = _copilotService.GetAllSessions(); + if (!sessions.Any(s => s.Name == sessionName)) + { + run.Error = $"Session '{sessionName}' not found"; + run.Success = false; + RecordRunAndSave(task, run); + return; + } + } + else + { + // Create a new session for this run + var timestamp = utcNow.ToLocalTime().ToString("MMdd-HHmm"); + sessionName = $"⏰ {task.Name} ({timestamp})"; + try + { + await _copilotService.CreateSessionAsync(sessionName, task.Model, task.WorkingDirectory); + } + catch (Exception ex) + { + run.Error = $"Failed to create session: {ex.Message}"; + run.Success = false; + RecordRunAndSave(task, run); + return; + } + } + + run.SessionName = sessionName; + await _copilotService.SendPromptAsync(sessionName, task.Prompt); + + run.CompletedAt = DateTime.UtcNow; + run.Success = true; + } + catch (Exception ex) + { + run.CompletedAt = DateTime.UtcNow; + run.Error = ex.Message; + run.Success = false; + System.Diagnostics.Debug.WriteLine($"[ScheduledTask] Execution failed for '{task.Name}': {ex.Message}"); + } + + RecordRunAndSave(task, run); + } + + private void RecordRunAndSave(ScheduledTask task, ScheduledTaskRun run) + { + lock (_lock) task.RecordRun(run); + SaveTasks(); + OnTasksChanged?.Invoke(); + } + + // ── Persistence ────────────────────────────────────────────────── + + internal void LoadTasks() + { + try + { + if (File.Exists(TasksFilePath)) + { + var json = File.ReadAllText(TasksFilePath); + var loaded = JsonSerializer.Deserialize>(json); + if (loaded != null) + { + lock (_lock) + { + _tasks.Clear(); + _tasks.AddRange(loaded); + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ScheduledTask] Failed to load tasks: {ex.Message}"); + } + } + + internal void SaveTasks() + { + try + { + List snapshot; + lock (_lock) snapshot = _tasks.ToList(); + + var dir = Path.GetDirectoryName(TasksFilePath)!; + Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(TasksFilePath, json); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ScheduledTask] Failed to save tasks: {ex.Message}"); + } + } + + private static string GetPolyPilotDir() + { +#if IOS || ANDROID + try + { + return Path.Combine(FileSystem.AppDataDirectory, ".polypilot"); + } + catch + { + var fallback = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(fallback)) + fallback = Path.GetTempPath(); + return Path.Combine(fallback, ".polypilot"); + } +#else + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".polypilot"); +#endif + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _evaluationTimer?.Dispose(); + _evaluationTimer = null; + } +} From aaa66b4bf31920f7498d90c631cef8365e9f26b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:05:21 +0000 Subject: [PATCH 3/4] Fix CSS font-size values to use type-scale variables per enforcement tests Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot/Components/Pages/ScheduledTasks.razor.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PolyPilot/Components/Pages/ScheduledTasks.razor.css b/PolyPilot/Components/Pages/ScheduledTasks.razor.css index 1fa4bd46..efa8be79 100644 --- a/PolyPilot/Components/Pages/ScheduledTasks.razor.css +++ b/PolyPilot/Components/Pages/ScheduledTasks.razor.css @@ -72,7 +72,7 @@ background: none; border: none; color: var(--text-dim); - font-size: 1.2rem; + font-size: var(--type-title3); cursor: pointer; padding: 0.2rem 0.4rem; } @@ -340,7 +340,7 @@ display: inline-block; margin-top: 0.4rem; padding: 0.15rem 0.5rem; - font-size: 0.75rem; + font-size: var(--type-callout); background: var(--hover-bg, rgba(124, 92, 252, 0.1)); border-radius: 4px; color: var(--text-dim); @@ -352,7 +352,7 @@ border-top: 1px solid var(--control-border); display: flex; justify-content: space-between; - font-size: 0.75rem; + font-size: var(--type-callout); color: var(--text-dim); } @@ -383,7 +383,7 @@ } .empty-icon { - font-size: 3rem; + font-size: var(--type-title1); margin-bottom: 1rem; } From 6e59cceb313d6352e02b91efafae8ae201324253 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:09:34 +0000 Subject: [PATCH 4/4] Address code review: fix Daily schedule logic, add evaluation overlap guard, improve timestamp format, optimize nextRun calculation Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../Components/Pages/ScheduledTasks.razor | 18 ++++-------- PolyPilot/Models/ScheduledTask.cs | 5 ++-- PolyPilot/Services/ScheduledTaskService.cs | 28 +++++++++++++------ 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/PolyPilot/Components/Pages/ScheduledTasks.razor b/PolyPilot/Components/Pages/ScheduledTasks.razor index a3324ade..2fb9f3ff 100644 --- a/PolyPilot/Components/Pages/ScheduledTasks.razor +++ b/PolyPilot/Components/Pages/ScheduledTasks.razor @@ -117,6 +117,7 @@
@foreach (var task in tasks) { + var nextRunTime = task.IsEnabled ? task.GetNextRunTimeUtc(DateTime.UtcNow) : null;
@@ -164,25 +165,16 @@ } - @{ - var nextRun = task.GetNextRunTimeUtc(DateTime.UtcNow); - } - @if (nextRun != null && task.IsEnabled) + @if (nextRunTime != null) { - Next: @FormatTimeAgo(nextRun.Value) + Next: @FormatTimeAgo(nextRunTime.Value) }
} - else if (task.IsEnabled) + else if (nextRunTime != null) { }
diff --git a/PolyPilot/Models/ScheduledTask.cs b/PolyPilot/Models/ScheduledTask.cs index 57ec6eab..54186739 100644 --- a/PolyPilot/Models/ScheduledTask.cs +++ b/PolyPilot/Models/ScheduledTask.cs @@ -99,8 +99,9 @@ public class ScheduledTask var (h, m) = ParseTimeOfDay(); var todayLocal = now.ToLocalTime().Date.AddHours(h).AddMinutes(m); var todayUtc = todayLocal.ToUniversalTime(); - if (LastRunAt == null) return todayUtc <= now ? todayUtc : todayUtc; - if (todayUtc > now && (LastRunAt == null || LastRunAt.Value.Date < now.ToLocalTime().Date)) + if (LastRunAt == null) + return todayUtc; // never run — schedule for today's slot (may be in the past, that's fine — IsDue will fire) + if (todayUtc > now && LastRunAt.Value.Date < now.ToLocalTime().Date) return todayUtc; // Next day return todayLocal.AddDays(1).ToUniversalTime(); diff --git a/PolyPilot/Services/ScheduledTaskService.cs b/PolyPilot/Services/ScheduledTaskService.cs index a3b17fa0..a56f8c68 100644 --- a/PolyPilot/Services/ScheduledTaskService.cs +++ b/PolyPilot/Services/ScheduledTaskService.cs @@ -20,6 +20,7 @@ public class ScheduledTaskService : IDisposable private readonly List _tasks = new(); private readonly object _lock = new(); private Timer? _evaluationTimer; + private int _evaluating; // Guard against overlapping evaluations private bool _disposed; /// Raised when any task list or state change occurs (for UI refresh). @@ -111,20 +112,31 @@ public void SetEnabled(string id, bool enabled) /// /// Evaluate all tasks and execute any that are due. /// Called by the background timer every 30 seconds. + /// Uses an interlocked guard to prevent overlapping evaluations. /// internal async Task EvaluateTasksAsync() { - List dueTasks; - var now = DateTime.UtcNow; + // Prevent overlapping evaluations if a previous run is still executing + if (Interlocked.CompareExchange(ref _evaluating, 1, 0) != 0) return; - lock (_lock) + try { - dueTasks = _tasks.Where(t => t.IsDue(now)).ToList(); - } + List dueTasks; + var now = DateTime.UtcNow; - foreach (var task in dueTasks) + lock (_lock) + { + dueTasks = _tasks.Where(t => t.IsDue(now)).ToList(); + } + + foreach (var task in dueTasks) + { + await ExecuteTaskAsync(task, now); + } + } + finally { - await ExecuteTaskAsync(task, now); + Interlocked.Exchange(ref _evaluating, 0); } } @@ -161,7 +173,7 @@ internal async Task ExecuteTaskAsync(ScheduledTask task, DateTime utcNow) else { // Create a new session for this run - var timestamp = utcNow.ToLocalTime().ToString("MMdd-HHmm"); + var timestamp = utcNow.ToLocalTime().ToString("MMM dd HH:mm"); sessionName = $"⏰ {task.Name} ({timestamp})"; try {