# Windows Service + Tray-GUI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a .NET Windows Service that runs `journal_bot ingest` every 15 minutes, and a WPF tray GUI that shows history/status/logs and triggers ingest/process/both manually. **Architecture:** Two thin .NET shells (`tools/JournalBot.Service`, `tools/JournalBot.Tray`) wrap the existing Python CLI. They communicate only via CLI subprocess calls and the `runtime/` filesystem. The Python `QueueItem` is extended with optional result fields so the GUI can show what was written where. Hybrid split: the service does only `ingest` (no LM Studio needed, runs as LocalSystem); the GUI does `process` (needs LM Studio, runs as user). **Tech Stack:** Python 3.12 (pydantic, pytest), .NET 10 (Worker Service, WPF, `Hardcodet.NotifyIcon.Wpf`, xUnit). .NET SDK 10.0.103 confirmed present. --- ## Conventions - **Python venv:** always `.venv/Scripts/python.exe` (uv not on PATH). - **Encoding:** all file I/O `encoding="utf-8"`. Never PowerShell `Set-Content`/`Get-Content` without `-Encoding UTF8`. - **Commits:** conventional, English, imperative. Commit per task. - **Paths:** project root `D:\projects\chrka\journal-bot`. .NET projects under `tools/`. --- ## Phase A — Python: extended history fields (TDD) ### Task 1: Add result fields to QueueItem **Files:** - Modify: `src/journal_bot/queue.py:6-14` (QueueItem model) - Test: `tests/test_queue.py` - [ ] **Step 1: Write the failing test** Add to `tests/test_queue.py`: ```python def test_queue_item_has_optional_result_fields(): item = QueueItem( update_id=1, received_at="2026-06-14T14:32:17+02:00", type="text", text="Hello", ) assert item.target_path is None assert item.written_entry is None assert item.processed_at is None ``` - [ ] **Step 2: Run test to verify it fails** Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py::test_queue_item_has_optional_result_fields -v` Expected: FAIL with `AttributeError` (no attribute `target_path`). - [ ] **Step 3: Add fields to the model** In `src/journal_bot/queue.py`, extend `QueueItem` (after `attempts`): ```python class QueueItem(BaseModel): update_id: int received_at: str # ISO 8601 type: str # "text" | "voice" | "photo" text: str = "" raw_audio_path: Optional[str] = None image_embed: Optional[str] = None image_caption: Optional[str] = None attempts: int = 0 # Result fields, filled by process after a successful write: target_path: Optional[str] = None # e.g. "05 Daily Notes/2026-06-15.md" written_entry: Optional[str] = None # the markdown entry actually written processed_at: Optional[str] = None # ISO 8601 ``` - [ ] **Step 4: Run test to verify it passes** Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py::test_queue_item_has_optional_result_fields -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/journal_bot/queue.py tests/test_queue.py git commit -m "feat(queue): add optional result fields to QueueItem" ``` --- ### Task 2: Persist updated item JSON in complete() `complete()` currently only renames working→done, which would discard the new result fields. Rewrite it like `fail()`: write the (possibly mutated) item JSON atomically into `done/`, then unlink the working source. **Files:** - Modify: `src/journal_bot/queue.py:49-52` (`complete` method) - Test: `tests/test_queue.py` - [ ] **Step 1: Write the failing test** Add to `tests/test_queue.py`: ```python def test_complete_persists_result_fields(queue, tmp_path): queue.enqueue(make_item(1)) item = queue.claim_next() item.target_path = "05 Daily Notes/2026-06-14.md" item.written_entry = "## 14:32\nHallo" item.processed_at = "2026-06-14T15:00:00+02:00" queue.complete(item) done_path = tmp_path / "done" / "1.json" assert done_path.exists() assert not (tmp_path / "working" / "1.json").exists() data = json.loads(done_path.read_text(encoding="utf-8")) assert data["target_path"] == "05 Daily Notes/2026-06-14.md" assert data["written_entry"] == "## 14:32\nHallo" assert data["processed_at"] == "2026-06-14T15:00:00+02:00" ``` - [ ] **Step 2: Run test to verify it fails** Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py::test_complete_persists_result_fields -v` Expected: FAIL — `data["target_path"]` is `None` (old `complete` did a plain rename, losing nothing but also persisting the pre-mutation file; actually the claimed file on disk still has null fields). Confirm it fails. - [ ] **Step 3: Rewrite `complete`** Replace `complete` in `src/journal_bot/queue.py`: ```python def complete(self, item: QueueItem) -> None: src = self._path(self.working, item.update_id) dst = self._path(self.done, item.update_id) # Mirror fail(): write the (mutated) item atomically into done/, # then remove the working source. Preserves crash-safety and # persists result fields set by the processor. tmp = dst.with_suffix(".tmp") tmp.write_text(item.model_dump_json(), encoding="utf-8") tmp.replace(dst) if src.exists(): src.unlink() ``` - [ ] **Step 4: Run tests to verify they pass** Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py -v` Expected: PASS (new test + existing `test_complete_moves_to_done`). - [ ] **Step 5: Commit** ```bash git add src/journal_bot/queue.py tests/test_queue.py git commit -m "feat(queue): persist mutated item JSON in complete()" ``` --- ### Task 3: Fill result fields in process_once `process_once` gets a new `processed_at: str` parameter (ISO 8601), passed in like `today` for testability (no hidden `datetime.now()`). It sets the three fields on the item before `queue.complete(item)`. **Files:** - Modify: `src/journal_bot/process.py:9-42` (signature + body) - Modify: `src/journal_bot/__main__.py:40-50` (`cmd_process` passes `processed_at`) - Test: `tests/test_process.py` - [ ] **Step 1: Write the failing test** Add to `tests/test_process.py` (top: `import json`): ```python def test_process_records_result_fields(tmp_path): vault = tmp_path / "vault" (vault / "05 Daily Notes").mkdir(parents=True) queue = Queue(tmp_path / "q") queue.enqueue(make_item(1)) processor = FakeProcessor(ProcessorOutput( target_date="2026-06-14", target_path="05 Daily Notes/2026-06-14.md", entry_markdown="## 14:32\nHallo", )) writer = VaultWriter(vault) process_once( processor, queue, writer, vault_path=vault, today=date(2026, 6, 14), processed_at="2026-06-14T15:00:00+02:00", ) data = json.loads( (tmp_path / "q" / "done" / "1.json").read_text(encoding="utf-8") ) assert data["target_path"] == "05 Daily Notes/2026-06-14.md" assert data["written_entry"] == "## 14:32\nHallo" assert data["processed_at"] == "2026-06-14T15:00:00+02:00" ``` - [ ] **Step 2: Run test to verify it fails** Run: `.venv/Scripts/python.exe -m pytest tests/test_process.py::test_process_records_result_fields -v` Expected: FAIL — `process_once()` got an unexpected keyword argument `processed_at`. - [ ] **Step 3: Update `process_once` signature and body** In `src/journal_bot/process.py`, change the signature and set fields before `complete`: ```python def process_once(processor, queue: Queue, writer: VaultWriter, vault_path: Path, today: date, processed_at: str) -> int: ``` Inside the `try`, replace the block from `writer.append(...)` through `queue.complete(item)` with: ```python writer.append(output.target_path, entry, clarifications=output.clarifications) item.target_path = output.target_path item.written_entry = entry item.processed_at = processed_at queue.complete(item) processed += 1 ``` - [ ] **Step 4: Update the only other caller (`cmd_process`)** In `src/journal_bot/__main__.py`, `cmd_process`, add the import and pass `processed_at`: At top of file (with other imports): `from datetime import date, datetime` (`date` is already imported; change the existing `from datetime import date` line to include `datetime`.) Change the `process_once(...)` call in `cmd_process`: ```python n = process_once( processor, queue, writer, vault_path=cfg.vault_path, today=date.today(), processed_at=datetime.now().astimezone().isoformat(), ) ``` - [ ] **Step 5: Run the full Python suite** Run: `.venv/Scripts/python.exe -m pytest -v` Expected: all PASS. (Existing `test_process_*` calls without `processed_at` — check: `test_process_writes_to_vault_and_completes`, `test_process_skips_when_unhealthy`, `test_process_fails_item_on_error` call `process_once` without the new arg → they will FAIL with missing positional arg.) - [ ] **Step 6: Fix the three existing process tests** In `tests/test_process.py`, add `processed_at="2026-06-14T15:00:00+02:00"` to the `process_once(...)` call in each of: - `test_process_writes_to_vault_and_completes` - `test_process_skips_when_unhealthy` - `test_process_fails_item_on_error` - [ ] **Step 7: Run the full Python suite again** Run: `.venv/Scripts/python.exe -m pytest -v` Expected: all PASS. - [ ] **Step 8: Commit** ```bash git add src/journal_bot/process.py src/journal_bot/__main__.py tests/test_process.py git commit -m "feat(process): record target_path, written_entry, processed_at on done items" ``` --- ## Phase B — .NET solution scaffold ### Task 4: Create solution and shared QueueItem DTO **Files:** - Create: `tools/JournalBot.sln` - Create: `tools/JournalBot.Shared/JournalBot.Shared.csproj` - Create: `tools/JournalBot.Shared/QueueItem.cs` - [ ] **Step 1: Scaffold the solution and shared lib** Run (from project root): ```bash dotnet new sln -o tools -n JournalBot dotnet new classlib -o tools/JournalBot.Shared -n JournalBot.Shared dotnet sln tools/JournalBot.sln add tools/JournalBot.Shared/JournalBot.Shared.csproj ``` Delete the generated `tools/JournalBot.Shared/Class1.cs`. - [ ] **Step 2: Write the QueueItem DTO** Create `tools/JournalBot.Shared/QueueItem.cs` — mirrors the Python pydantic model. `snake_case` JSON, so use `[JsonPropertyName]`: ```csharp using System.Text.Json.Serialization; namespace JournalBot.Shared; public sealed class QueueItem { [JsonPropertyName("update_id")] public long UpdateId { get; set; } [JsonPropertyName("received_at")] public string ReceivedAt { get; set; } = ""; [JsonPropertyName("type")] public string Type { get; set; } = ""; [JsonPropertyName("text")] public string Text { get; set; } = ""; [JsonPropertyName("raw_audio_path")] public string? RawAudioPath { get; set; } [JsonPropertyName("image_embed")] public string? ImageEmbed { get; set; } [JsonPropertyName("image_caption")] public string? ImageCaption { get; set; } [JsonPropertyName("attempts")] public int Attempts { get; set; } [JsonPropertyName("target_path")] public string? TargetPath { get; set; } [JsonPropertyName("written_entry")] public string? WrittenEntry { get; set; } [JsonPropertyName("processed_at")] public string? ProcessedAt { get; set; } } ``` - [ ] **Step 3: Build to verify it compiles** Run: `dotnet build tools/JournalBot.Shared/JournalBot.Shared.csproj` Expected: Build succeeded. - [ ] **Step 4: Commit** ```bash git add tools/JournalBot.sln tools/JournalBot.Shared git commit -m "feat(tools): scaffold .NET solution with shared QueueItem DTO" ``` --- ## Phase C — .NET: BotRunner + RuntimeReader (with xUnit tests) ### Task 5: BotRunner (subprocess wrapper) `BotRunner` runs `python.exe -m journal_bot ` in a working dir, captures stdout/stderr, returns exit code + combined output. Used by both Service and Tray. **Files:** - Create: `tools/JournalBot.Shared/BotRunner.cs` - Create: `tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj` - Create: `tools/JournalBot.Shared.Tests/BotRunnerTests.cs` - [ ] **Step 1: Scaffold the test project** ```bash dotnet new xunit -o tools/JournalBot.Shared.Tests -n JournalBot.Shared.Tests dotnet sln tools/JournalBot.sln add tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj dotnet add tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj reference tools/JournalBot.Shared/JournalBot.Shared.csproj ``` Delete generated `tools/JournalBot.Shared.Tests/UnitTest1.cs`. - [ ] **Step 2: Write the failing test** `BotRunner` is testable by running a trivial cross-platform program. Use the `python` executable itself with `-c` to avoid depending on the bot. Create `tools/JournalBot.Shared.Tests/BotRunnerTests.cs`: ```csharp using JournalBot.Shared; using Xunit; public class BotRunnerTests { // Locate the venv python; skip if absent (CI without venv). private static string? VenvPython() { var root = FindRepoRoot(); if (root is null) return null; var py = Path.Combine(root, ".venv", "Scripts", "python.exe"); return File.Exists(py) ? py : null; } private static string? FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "pyproject.toml"))) dir = dir.Parent; return dir?.FullName; } [Fact] public async Task Run_capturesStdoutAndExitCode() { var py = VenvPython(); if (py is null) return; // environment without venv; nothing to assert var runner = new BotRunner(py, FindRepoRoot()!); var result = await runner.RunRawAsync(new[] { "-c", "print('hi')" }); Assert.Equal(0, result.ExitCode); Assert.Contains("hi", result.Output); } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj` Expected: FAIL to compile — `BotRunner` / `RunRawAsync` not defined. - [ ] **Step 4: Implement BotRunner** Create `tools/JournalBot.Shared/BotRunner.cs`: ```csharp using System.Diagnostics; using System.Text; namespace JournalBot.Shared; public sealed record BotResult(int ExitCode, string Output); public sealed class BotRunner { private readonly string _pythonExe; private readonly string _workingDir; public BotRunner(string pythonExe, string workingDir) { _pythonExe = pythonExe; _workingDir = workingDir; } // Runs `python -m journal_bot `. public Task RunAsync(string command, CancellationToken ct = default) => RunRawAsync(new[] { "-m", "journal_bot", command }, ct); // Runs python with arbitrary args (used by tests and RunAsync). public async Task RunRawAsync(IReadOnlyList args, CancellationToken ct = default) { var psi = new ProcessStartInfo { FileName = _pythonExe, WorkingDirectory = _workingDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (var a in args) psi.ArgumentList.Add(a); var sb = new StringBuilder(); using var proc = new Process { StartInfo = psi }; proc.OutputDataReceived += (_, e) => { if (e.Data is not null) sb.AppendLine(e.Data); }; proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) sb.AppendLine(e.Data); }; proc.Start(); proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); await proc.WaitForExitAsync(ct); return new BotResult(proc.ExitCode, sb.ToString()); } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj` Expected: PASS (or no-op return if venv absent — acceptable). - [ ] **Step 6: Commit** ```bash git add tools/JournalBot.Shared/BotRunner.cs tools/JournalBot.Shared.Tests git commit -m "feat(tools): add BotRunner subprocess wrapper with test" ``` --- ### Task 6: RuntimeReader (reads queue dirs + logs) `RuntimeReader` reads `done/`, counts `pending/` + `failed/`, and tails log files. Pure file I/O → fully unit-testable with a temp dir. **Files:** - Create: `tools/JournalBot.Shared/RuntimeReader.cs` - Create: `tools/JournalBot.Shared.Tests/RuntimeReaderTests.cs` - [ ] **Step 1: Write the failing tests** Create `tools/JournalBot.Shared.Tests/RuntimeReaderTests.cs`: ```csharp using JournalBot.Shared; using Xunit; public class RuntimeReaderTests { private static string MakeRuntime() { var root = Path.Combine(Path.GetTempPath(), "jb_test_" + Guid.NewGuid().ToString("N")); foreach (var sub in new[] { "queue/pending", "queue/done", "queue/failed", "logs" }) Directory.CreateDirectory(Path.Combine(root, sub)); return root; } [Fact] public void ReadHistory_parsesDoneItemsNewestFirst() { var root = MakeRuntime(); var done = Path.Combine(root, "queue", "done"); File.WriteAllText(Path.Combine(done, "1.json"), "{\"update_id\":1,\"received_at\":\"2026-06-14T10:00:00+02:00\",\"type\":\"text\",\"text\":\"a\",\"target_path\":\"05 Daily Notes/2026-06-14.md\",\"processed_at\":\"2026-06-14T11:00:00+02:00\"}"); File.WriteAllText(Path.Combine(done, "2.json"), "{\"update_id\":2,\"received_at\":\"2026-06-14T12:00:00+02:00\",\"type\":\"voice\",\"text\":\"b\",\"processed_at\":\"2026-06-14T13:00:00+02:00\"}"); var reader = new RuntimeReader(root); var history = reader.ReadHistory(); Assert.Equal(2, history.Count); Assert.Equal(2, history[0].UpdateId); // newest processed_at first Assert.Equal("05 Daily Notes/2026-06-14.md", history[1].TargetPath); } [Fact] public void ReadStatus_countsPendingAndFailed() { var root = MakeRuntime(); File.WriteAllText(Path.Combine(root, "queue", "pending", "1.json"), "{}"); File.WriteAllText(Path.Combine(root, "queue", "pending", "2.json"), "{}"); File.WriteAllText(Path.Combine(root, "queue", "failed", "3.json"), "{}"); var status = new RuntimeReader(root).ReadStatus(); Assert.Equal(2, status.Pending); Assert.Equal(1, status.Failed); } [Fact] public void TailLog_returnsLastLines() { var root = MakeRuntime(); File.WriteAllText(Path.Combine(root, "logs", "service.log"), string.Join("\n", Enumerable.Range(1, 100).Select(i => $"line {i}"))); var lines = new RuntimeReader(root).TailLog("service.log", 5); Assert.Equal(5, lines.Count); Assert.Equal("line 100", lines[^1]); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj` Expected: FAIL to compile — `RuntimeReader` not defined. - [ ] **Step 3: Implement RuntimeReader** Create `tools/JournalBot.Shared/RuntimeReader.cs`: ```csharp using System.Text.Json; namespace JournalBot.Shared; public sealed record QueueStatus(int Pending, int Failed); public sealed class RuntimeReader { private readonly string _root; public RuntimeReader(string runtimeRoot) => _root = runtimeRoot; private string Queue(string sub) => Path.Combine(_root, "queue", sub); public IReadOnlyList ReadHistory() { var dir = Queue("done"); if (!Directory.Exists(dir)) return Array.Empty(); var items = new List(); foreach (var f in Directory.EnumerateFiles(dir, "*.json")) { try { var item = JsonSerializer.Deserialize(File.ReadAllText(f)); if (item is not null) items.Add(item); } catch (JsonException) { /* skip malformed */ } } // Newest first by processed_at (fallback received_at). return items .OrderByDescending(i => i.ProcessedAt ?? i.ReceivedAt, StringComparer.Ordinal) .ToList(); } public QueueStatus ReadStatus() { int Count(string sub) => Directory.Exists(Queue(sub)) ? Directory.EnumerateFiles(Queue(sub), "*.json").Count() : 0; return new QueueStatus(Count("pending"), Count("failed")); } public IReadOnlyList TailLog(string fileName, int lines) { var path = Path.Combine(_root, "logs", fileName); if (!File.Exists(path)) return Array.Empty(); var all = File.ReadAllLines(path); return all.Length <= lines ? all : all[^lines..]; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add tools/JournalBot.Shared/RuntimeReader.cs tools/JournalBot.Shared.Tests/RuntimeReaderTests.cs git commit -m "feat(tools): add RuntimeReader for history, status, log tail" ``` --- ## Phase D — .NET: Windows Service ### Task 7: Worker Service that runs ingest on a timer **Files:** - Create: `tools/JournalBot.Service/JournalBot.Service.csproj` - Create: `tools/JournalBot.Service/Program.cs` - Create: `tools/JournalBot.Service/Worker.cs` - Create: `tools/JournalBot.Service/appsettings.json` - [ ] **Step 1: Scaffold the worker project** ```bash dotnet new worker -o tools/JournalBot.Service -n JournalBot.Service dotnet sln tools/JournalBot.sln add tools/JournalBot.Service/JournalBot.Service.csproj dotnet add tools/JournalBot.Service/JournalBot.Service.csproj reference tools/JournalBot.Shared/JournalBot.Shared.csproj dotnet add tools/JournalBot.Service/JournalBot.Service.csproj package Microsoft.Extensions.Hosting.WindowsServices ``` Delete the generated `tools/JournalBot.Service/Worker.cs` (we replace it below). - [ ] **Step 2: Write appsettings.json** Create `tools/JournalBot.Service/appsettings.json`: ```json { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } }, "JournalBot": { "PythonExe": "D:\\projects\\chrka\\journal-bot\\.venv\\Scripts\\python.exe", "WorkingDir": "D:\\projects\\chrka\\journal-bot", "IntervalMinutes": 15 } } ``` - [ ] **Step 3: Write Program.cs** Create `tools/JournalBot.Service/Program.cs`: ```csharp using JournalBot.Service; var builder = Host.CreateApplicationBuilder(args); builder.Services.AddWindowsService(o => o.ServiceName = "JournalBotService"); builder.Services.AddHostedService(); var host = builder.Build(); host.Run(); ``` - [ ] **Step 4: Write Worker.cs** Create `tools/JournalBot.Service/Worker.cs`: ```csharp using JournalBot.Shared; namespace JournalBot.Service; public sealed class Worker : BackgroundService { private readonly ILogger _log; private readonly BotRunner _runner; private readonly TimeSpan _interval; private readonly string _serviceLogPath; public Worker(ILogger log, IConfiguration config) { _log = log; var py = config["JournalBot:PythonExe"]!; var workingDir = config["JournalBot:WorkingDir"]!; var minutes = config.GetValue("JournalBot:IntervalMinutes", 15); _runner = new BotRunner(py, workingDir); _interval = TimeSpan.FromMinutes(minutes); _serviceLogPath = Path.Combine(workingDir, "runtime", "logs", "service.log"); Directory.CreateDirectory(Path.GetDirectoryName(_serviceLogPath)!); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Run once at startup, then every interval. await RunIngestAsync(stoppingToken); using var timer = new PeriodicTimer(_interval); while (await timer.WaitForNextTickAsync(stoppingToken)) await RunIngestAsync(stoppingToken); } private async Task RunIngestAsync(CancellationToken ct) { try { var result = await _runner.RunAsync("ingest", ct); var line = $"[{DateTimeOffset.Now:O}] ingest exit={result.ExitCode}"; _log.LogInformation("{Line}", line); await AppendLogAsync(line + "\n" + result.Output); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _log.LogError(ex, "ingest run failed"); await AppendLogAsync($"[{DateTimeOffset.Now:O}] ingest ERROR: {ex.Message}"); } } private async Task AppendLogAsync(string text) { try { await File.AppendAllTextAsync(_serviceLogPath, text + "\n", System.Text.Encoding.UTF8); } catch { /* logging must never crash the service */ } } } ``` - [ ] **Step 5: Build to verify it compiles** Run: `dotnet build tools/JournalBot.Service/JournalBot.Service.csproj` Expected: Build succeeded. - [ ] **Step 6: Commit** ```bash git add tools/JournalBot.Service git commit -m "feat(service): windows service runs ingest every 15 min via BotRunner" ``` --- ## Phase E — .NET: WPF Tray GUI ### Task 8: WPF project with tray icon and window shell **Files:** - Create: `tools/JournalBot.Tray/JournalBot.Tray.csproj` - Create: `tools/JournalBot.Tray/App.xaml` - Create: `tools/JournalBot.Tray/App.xaml.cs` - Create: `tools/JournalBot.Tray/MainWindow.xaml` - Create: `tools/JournalBot.Tray/MainWindow.xaml.cs` - Create: `tools/JournalBot.Tray/appsettings.json` - Create: `tools/JournalBot.Tray/icon.ico` (placeholder, see step) - [ ] **Step 1: Scaffold the WPF project** ```bash dotnet new wpf -o tools/JournalBot.Tray -n JournalBot.Tray dotnet sln tools/JournalBot.sln add tools/JournalBot.Tray/JournalBot.Tray.csproj dotnet add tools/JournalBot.Tray/JournalBot.Tray.csproj reference tools/JournalBot.Shared/JournalBot.Shared.csproj dotnet add tools/JournalBot.Tray/JournalBot.Tray.csproj package Hardcodet.NotifyIcon.Wpf dotnet add tools/JournalBot.Tray/JournalBot.Tray.csproj package Microsoft.Extensions.Configuration.Json ``` - [ ] **Step 2: Add an icon** Copy any existing `.ico` to `tools/JournalBot.Tray/icon.ico`. If none exists, generate a 1-color placeholder: ```bash .venv/Scripts/python.exe -c "from PIL import Image; Image.new('RGBA',(32,32),(30,90,200,255)).save('tools/JournalBot.Tray/icon.ico')" ``` In `tools/JournalBot.Tray/JournalBot.Tray.csproj`, inside an ``, mark it as resource: ```xml ``` - [ ] **Step 3: Write appsettings.json** Create `tools/JournalBot.Tray/appsettings.json`: ```json { "JournalBot": { "PythonExe": "D:\\projects\\chrka\\journal-bot\\.venv\\Scripts\\python.exe", "WorkingDir": "D:\\projects\\chrka\\journal-bot", "RuntimePath": "D:\\projects\\chrka\\journal-bot\\runtime", "ProcessTimerMinutes": 0 } } ``` In the `.csproj`, ensure it is copied next to the EXE — add inside an ``: ```xml PreserveNewest ``` - [ ] **Step 4: Write App.xaml (no StartupUri; tray-driven)** Replace `tools/JournalBot.Tray/App.xaml`: ```xml ``` - [ ] **Step 5: Write App.xaml.cs (sets up tray icon + config)** Replace `tools/JournalBot.Tray/App.xaml.cs`: ```csharp using System.IO; using System.Windows; using Hardcodet.Wpf.TaskbarNotification; using JournalBot.Shared; using Microsoft.Extensions.Configuration; namespace JournalBot.Tray; public partial class App : Application { private TaskbarIcon? _tray; private MainWindow? _window; private BotRunner _runner = null!; private RuntimeReader _reader = null!; protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false) .Build(); var py = config["JournalBot:PythonExe"]!; var workingDir = config["JournalBot:WorkingDir"]!; var runtimePath = config["JournalBot:RuntimePath"]!; _runner = new BotRunner(py, workingDir); _reader = new RuntimeReader(runtimePath); _window = new MainWindow(_runner, _reader); _tray = new TaskbarIcon { ToolTipText = "JournalBot", Icon = new System.Drawing.Icon( Path.Combine(AppContext.BaseDirectory, "icon.ico")), }; _tray.TrayLeftMouseUp += (_, _) => ToggleWindow(); _tray.ContextMenu = BuildMenu(); } private System.Windows.Controls.ContextMenu BuildMenu() { var menu = new System.Windows.Controls.ContextMenu(); void Add(string header, Action onClick) { var mi = new System.Windows.Controls.MenuItem { Header = header }; mi.Click += (_, _) => onClick(); menu.Items.Add(mi); } Add("Ingest", () => _window!.TriggerCommand("ingest")); Add("Process", () => _window!.TriggerCommand("process")); Add("Both", () => _window!.TriggerCommand("both")); menu.Items.Add(new System.Windows.Controls.Separator()); Add("Fenster öffnen", ShowWindow); Add("Beenden", () => Shutdown()); return menu; } private void ToggleWindow() { if (_window!.IsVisible) _window.Hide(); else ShowWindow(); } private void ShowWindow() { _window!.Show(); _window.Activate(); } protected override void OnExit(ExitEventArgs e) { _tray?.Dispose(); base.OnExit(e); } } ``` - [ ] **Step 6: Build to verify it compiles** Run: `dotnet build tools/JournalBot.Tray/JournalBot.Tray.csproj` Expected: Build succeeded. (MainWindow ctor with `(BotRunner, RuntimeReader)` will be defined in Task 9 — until then this fails to compile. So defer the build to Task 9.) - [ ] **Step 7: Commit** ```bash git add tools/JournalBot.Tray git commit -m "feat(tray): WPF app shell with tray icon and context menu" ``` --- ### Task 9: MainWindow — status banner, history tab, log tab, buttons **Files:** - Modify: `tools/JournalBot.Tray/MainWindow.xaml` - Modify: `tools/JournalBot.Tray/MainWindow.xaml.cs` - [ ] **Step 1: Write MainWindow.xaml** Replace `tools/JournalBot.Tray/MainWindow.xaml`: ```xml