diff --git a/docs/plans/2026-02-27-idle-detection-plan.md b/docs/plans/2026-02-27-idle-detection-plan.md new file mode 100644 index 0000000..d04951f --- /dev/null +++ b/docs/plans/2026-02-27-idle-detection-plan.md @@ -0,0 +1,263 @@ +# Idle Detection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Auto-pause the active TaskTracker task when the user is idle, and auto-resume it when they return. + +**Architecture:** Add `GetLastInputInfo` P/Invoke to `NativeMethods.cs`, check idle time on every existing poll cycle in `Worker.cs`, and call the TaskTracker pause/resume API on state transitions. No new threads, timers, or services needed. + +**Tech Stack:** .NET 10, Win32 `user32.dll` P/Invoke, existing `IHttpClientFactory` + +--- + +### Task 1: Add GetLastInputInfo to NativeMethods + +**Files:** +- Modify: `WindowWatcher/NativeMethods.cs` + +**Step 1: Add the LASTINPUTINFO struct and GetLastInputInfo import** + +Add the following to `NativeMethods.cs`: + +```csharp +[StructLayout(LayoutKind.Sequential)] +internal struct LASTINPUTINFO +{ + public uint cbSize; + public uint dwTime; +} + +[DllImport("user32.dll")] +internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii); +``` + +Note: `GetLastInputInfo` must use `DllImport`, not `LibraryImport`, because it takes a `ref` struct with `cbSize` that needs manual marshalling. + +**Step 2: Add a helper method for getting idle time** + +```csharp +internal static TimeSpan GetIdleTime() +{ + var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf() }; + if (!GetLastInputInfo(ref info)) + return TimeSpan.Zero; + return TimeSpan.FromMilliseconds(Environment.TickCount64 - info.dwTime); +} +``` + +**Step 3: Commit** + +```bash +git add WindowWatcher/NativeMethods.cs +git commit -m "feat(watcher): add GetLastInputInfo P/Invoke for idle detection" +``` + +--- + +### Task 2: Add IdleTimeoutMs to configuration + +**Files:** +- Modify: `WindowWatcher/WindowWatcherOptions.cs` +- Modify: `WindowWatcher/appsettings.json` + +**Step 1: Add IdleTimeoutMs property** + +In `WindowWatcherOptions.cs`, add: + +```csharp +public int IdleTimeoutMs { get; set; } = 300_000; +``` + +**Step 2: Add to appsettings.json** + +Add `"IdleTimeoutMs": 300000` to the `"WindowWatcher"` section: + +```json +"WindowWatcher": { + "ApiBaseUrl": "http://localhost:5200", + "PollIntervalMs": 2000, + "DebounceMs": 3000, + "IdleTimeoutMs": 300000 +} +``` + +**Step 3: Commit** + +```bash +git add WindowWatcher/WindowWatcherOptions.cs WindowWatcher/appsettings.json +git commit -m "feat(watcher): add configurable IdleTimeoutMs setting (default 5 min)" +``` + +--- + +### Task 3: Add idle detection logic to Worker + +**Files:** +- Modify: `WindowWatcher/Worker.cs` + +This is the main task. Add idle state tracking and API calls for pause/resume. + +**Step 1: Add idle tracking fields** + +Add these fields to the `Worker` class (after the existing `_lastChangeTime` field): + +```csharp +private bool _isIdle; +private int? _pausedTaskId; +``` + +**Step 2: Add idle check to the polling loop** + +At the end of the `try` block in `ExecuteAsync` (after the window-change detection block, before the catch), add: + +```csharp +// Idle detection +var idleTime = NativeMethods.GetIdleTime(); +if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs) +{ + _isIdle = true; + logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime); + await PauseActiveTaskAsync(ct: stoppingToken); +} +else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs) +{ + _isIdle = false; + logger.LogInformation("User returned from idle"); + await ResumeIdlePausedTaskAsync(ct: stoppingToken); +} +``` + +**Step 3: Update the startup log to include idle timeout** + +Change the existing `LogInformation` line to: + +```csharp +logger.LogInformation( + "WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms", + config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs); +``` + +**Step 4: Add PauseActiveTaskAsync method** + +```csharp +private async Task PauseActiveTaskAsync(CancellationToken ct) +{ + try + { + var client = httpClientFactory.CreateClient("TaskTrackerApi"); + + // Get the active task + var response = await client.GetFromJsonAsync>( + "/api/tasks/active", ct); + + if (response?.Data is null) + { + logger.LogDebug("No active task to pause"); + return; + } + + _pausedTaskId = response.Data.Id; + + // Pause it + var pauseResponse = await client.PutAsJsonAsync( + $"/api/tasks/{_pausedTaskId}/pause", + new { note = "Auto-paused: idle timeout" }, ct); + + if (pauseResponse.IsSuccessStatusCode) + logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId); + else + logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to pause active task on idle"); + } +} +``` + +**Step 5: Add ResumeIdlePausedTaskAsync method** + +```csharp +private async Task ResumeIdlePausedTaskAsync(CancellationToken ct) +{ + if (_pausedTaskId is null) + return; + + var taskId = _pausedTaskId.Value; + _pausedTaskId = null; + + try + { + var client = httpClientFactory.CreateClient("TaskTrackerApi"); + + // Check the task is still paused (user may have manually switched tasks) + var response = await client.GetFromJsonAsync>( + $"/api/tasks/{taskId}", ct); + + if (response?.Data is null || response.Data.Status != "Paused") + { + logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId); + return; + } + + // Resume it + var resumeResponse = await client.PutAsJsonAsync( + $"/api/tasks/{taskId}/resume", + new { note = "Auto-resumed: user returned" }, ct); + + if (resumeResponse.IsSuccessStatusCode) + logger.LogInformation("Auto-resumed task {TaskId}", taskId); + else + logger.LogWarning("Failed to resume task {TaskId}: {Status}", taskId, resumeResponse.StatusCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to resume task {TaskId} after idle", taskId); + } +} +``` + +**Step 6: Add the minimal DTO for deserializing API responses** + +Add a simple record at the bottom of `Worker.cs` (file-scoped, internal): + +```csharp +internal record ActiveTaskDto(int Id, string Status); +``` + +This is all we need to deserialize from the `ApiResponse` wrapper — `System.Text.Json` will ignore extra properties. + +**Step 7: Add the missing using for JSON deserialization** + +Verify `using System.Net.Http.Json;` already exists (it does — line 2 of Worker.cs). No changes needed. + +**Step 8: Commit** + +```bash +git add WindowWatcher/Worker.cs +git commit -m "feat(watcher): add idle detection with auto-pause/resume" +``` + +--- + +### Task 4: Build and verify + +**Step 1: Build the project** + +```bash +cd WindowWatcher && dotnet build +``` + +Expected: Build succeeded with 0 errors. + +**Step 2: Fix any build errors** + +If there are errors, fix them. + +**Step 3: Commit any fixes** + +If fixes were needed: + +```bash +git add -A && git commit -m "fix(watcher): fix build errors in idle detection" +```