feat(usage): PowerUse-Zeitfenster + Handoff-Schwellwert als Settings

Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).

Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.

Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
This commit is contained in:
mika kuns
2026-09-02 19:09:47 +02:00
parent 20b3b4ce2b
commit 69898be5c9
21 changed files with 1596 additions and 14 deletions
@@ -134,6 +134,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
// here, but they must survive a Settings save.
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
public async Task LoadAsync()
{
IsBusy = true;
@@ -156,6 +160,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -224,7 +229,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs);
General.ListHandlerMaxHandoffs,
_powerUseWindow.Start,
_powerUseWindow.End,
_powerUseWindow.HandoffPct);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -108,6 +109,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
[ObservableProperty] private string? _powerUseWindowStart;
[ObservableProperty] private string? _powerUseWindowEnd;
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
[ObservableProperty] private int _powerUseHandoffPct = 95;
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
// write it straight back to the worker.
private bool _loadingPowerUse;
@@ -115,13 +124,16 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUseAsync(value);
_ = SavePowerUse();
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own.
private async Task SavePowerUseAsync(bool value)
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
/// value the user just typed into a sibling box.
[RelayCommand]
private async Task SavePowerUse()
{
if (_loadingPowerUse) return;
try
{
var settings = await _worker.GetAppSettingsAsync();
@@ -131,7 +143,28 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
return;
}
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
// The worker stores an unparseable time as null; normalize here too so the box shows
// what was actually saved instead of a typo that looks like a live window.
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
await _worker.UpdateAppSettingsAsync(settings with
{
PowerUseEnabled = PowerUseEnabled,
PowerUseWindowStart = start,
PowerUseWindowEnd = end,
PowerUseHandoffPct = pct,
});
_loadingPowerUse = true;
try
{
PowerUseWindowStart = start;
PowerUseWindowEnd = end;
PowerUseHandoffPct = pct;
}
finally { _loadingPowerUse = false; }
}
catch (Exception ex)
{
@@ -192,7 +225,13 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
if (settings is not null)
{
_loadingPowerUse = true;
try { PowerUseEnabled = settings.PowerUseEnabled; }
try
{
PowerUseEnabled = settings.PowerUseEnabled;
PowerUseWindowStart = settings.PowerUseWindowStart;
PowerUseWindowEnd = settings.PowerUseWindowEnd;
PowerUseHandoffPct = settings.PowerUseHandoffPct;
}
finally { _loadingPowerUse = false; }
}