Add utility classes for theme management, waiting, watched products, and notifications

- Implement ThemeManager for dynamic light/dark theme switching in the application.
- Create Wait class for cancellable delays without exceptions for smoother user experience.
- Introduce WatchedProductsStore to manage and persist watched products in JSON format.
- Add WindowsNotifier for system notifications to inform users of important events.
- Develop ProductViewModel to encapsulate product data and manage UI interactions effectively.
This commit is contained in:
2026-08-04 21:49:53 +02:00
parent 551697d98d
commit 7ca504a70a
212 changed files with 112225 additions and 26787 deletions
+247
View File
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Utilities
{
/// <summary>
/// Misura l'anticipo <b>effettivo</b> delle puntate e ne ricava un consiglio.
///
/// <para>L'anticipo impostato dall'utente è una scommessa: "parto 800 ms prima della
/// scadenza". Ma ciò che conta è dove la puntata arriva davvero, cioè quanto restava
/// alla scadenza nell'istante in cui il server l'ha accettata. Fra i due valori ci
/// sono il ritardo di rete e l'errore di attesa.</para>
///
/// <para>Il motore calcolava già questo numero a ogni puntata, ma lo scriveva solo nel
/// log dell'asta, dove nessuno lo poteva sommare. Qui viene raccolto: con qualche
/// decina di misure si vede se l'anticipo impostato è troppo largo — e ogni
/// millisecondo di troppo è tempo in cui un avversario può passare davanti.</para>
/// </summary>
public static class BidLeadStats
{
/// <summary>Misure conservate. Oltre, si dimenticano le più vecchie.</summary>
private const int MaxSamples = 500;
private static readonly object Sync = new();
private static Store? _cache;
public class Sample
{
public DateTime At { get; set; }
public string AuctionId { get; set; } = "";
/// <summary>Anticipo impostato per quell'asta.</summary>
public int ConfiguredMs { get; set; }
/// <summary>Millisecondi realmente mancanti alla scadenza quando è partita.</summary>
public double ActualMs { get; set; }
/// <summary>Ping medio osservato su quell'asta al momento della puntata.</summary>
public double PingMs { get; set; }
public bool Success { get; set; }
/// <summary>Il server ha rifiutato perché il tempo era scaduto.</summary>
public bool TooLate { get; set; }
}
private class Store
{
public List<Sample> Samples { get; set; } = new();
}
private static Store Load()
{
lock (Sync)
{
if (_cache != null) return _cache;
try
{
var path = AppPaths.BidLeadStatsFile;
_cache = File.Exists(path)
? JsonSerializer.Deserialize<Store>(File.ReadAllText(path)) ?? new Store()
: new Store();
}
catch
{
_cache = new Store();
}
return _cache;
}
}
private static void Save()
{
try
{
AppPaths.EnsureFolders();
lock (Sync)
{
File.WriteAllText(AppPaths.BidLeadStatsFile,
JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }));
}
}
catch { /* una misura persa non giustifica un errore all'utente */ }
}
public static void Record(Sample sample)
{
lock (Sync)
{
var store = Load();
store.Samples.Add(sample);
if (store.Samples.Count > MaxSamples)
store.Samples.RemoveRange(0, store.Samples.Count - MaxSamples);
}
Save();
}
public static List<Sample> All()
{
lock (Sync) return Load().Samples.ToList();
}
public static void Clear()
{
lock (Sync) Load().Samples.Clear();
Save();
}
/// <summary>Riepilogo delle misure raccolte.</summary>
public readonly record struct Summary(
int Count,
int Successes,
int TooLate,
double AverageActualMs,
double MedianActualMs,
double MinActualMs,
double AveragePingMs,
int TypicalConfiguredMs)
{
public bool HasData => Count > 0;
public double SuccessRate => Count > 0 ? Successes * 100.0 / Count : 0;
}
public static Summary Summarize()
{
var samples = All();
if (samples.Count == 0) return default;
// Solo le puntate accettate dicono qualcosa sull'anticipo: una rifiutata non
// è mai arrivata a destinazione e il suo "anticipo" non è confrontabile.
var accepted = samples.Where(s => s.Success).ToList();
var basis = accepted.Count > 0 ? accepted : samples;
var actual = basis.Select(s => s.ActualMs).OrderBy(v => v).ToList();
return new Summary(
Count: samples.Count,
Successes: samples.Count(s => s.Success),
TooLate: samples.Count(s => s.TooLate),
AverageActualMs: actual.Average(),
MedianActualMs: actual[actual.Count / 2],
MinActualMs: actual[0],
AveragePingMs: basis.Average(s => s.PingMs),
TypicalConfiguredMs: (int)basis.Average(s => s.ConfiguredMs));
}
/// <summary>Esito della taratura: un consiglio, mai un'applicazione automatica.</summary>
public readonly record struct Advice(bool HasAdvice, int SuggestedLeadMs, string Reason);
/// <summary>
/// Propone un anticipo a partire dalle misure.
///
/// <para>Il criterio è prudente per costruzione. Il margine minimo che va comunque
/// lasciato è il ritardo di rete più un cuscinetto: sotto quella soglia la puntata
/// rischia di arrivare a giochi chiusi, e una puntata tardiva costa un'asta intera
/// mentre un anticipo un po' largo costa solo qualche possibilità in più
/// all'avversario. Per questo si scende solo se lo scarto è ampio, e mai sotto il
/// margine di sicurezza.</para>
/// </summary>
/// <summary>
/// Anticipo minimo sensato, in millisecondi. Sotto questa soglia la puntata
/// dipende dal non avere un picco di rete: sui dossier raccolti il ping è
/// mediano 56 ms ma il p99,9 è 444 ms, quindi con 200 ms di anticipo lo 0,43%
/// delle puntate arriverebbe dopo la scadenza — e una tardiva costa l'asta.
/// </summary>
public const int SafeFloorMs = 600;
/// <summary>
/// Anticipo oltre il quale si comincia a pagare. Il motore punta in ogni ciclo che
/// arriva fino all'anticipo senza puntate altrui, e su 1.007.850 cicli rigiocati la
/// mediana delle puntate per asta è 1 sotto il secondo, 15 sotto i due secondi,
/// 46 sotto i tre e 97 sotto i quattro. Ogni secondo in più moltiplica per dieci
/// la spesa, quindi il consiglio non supera mai questo valore.
/// </summary>
public const int SafeCeilingMs = 2000;
public static Advice Suggest(int currentLeadMs, int minSamples = 15)
{
var samples = All().Where(s => s.Success).ToList();
if (samples.Count < minSamples)
{
// Anche senza misure proprie qualcosa si può dire: le due soglie sono
// ricavate dai dossier, non da un'intuizione.
if (currentLeadMs > 0 && currentLeadMs < SafeFloorMs)
return new Advice(true, SafeFloorMs,
$"Anticipo {currentLeadMs} ms: sotto i {SafeFloorMs} ms basta un picco di rete " +
$"perché la puntata arrivi a giochi chiusi. Servono comunque {minSamples} puntate " +
$"riuscite per una taratura sulle tue misure (finora {samples.Count}).");
if (currentLeadMs > SafeCeilingMs)
return new Advice(true, SafeCeilingMs,
$"Anticipo {currentLeadMs} ms: oltre i {SafeCeilingMs} ms si punta in molti più cicli " +
$"del necessario, e ogni ciclo in più è una puntata spesa. Servono comunque " +
$"{minSamples} puntate riuscite per una taratura sulle tue misure (finora {samples.Count}).");
return new Advice(false, currentLeadMs,
$"Servono almeno {minSamples} puntate riuscite per un consiglio attendibile (finora {samples.Count}).");
}
var tooLate = All().Count(s => s.TooLate);
// Se arrivano puntate tardive il problema è opposto: bisogna allargare.
if (tooLate > 0)
{
var ping = samples.Average(s => s.PingMs);
var suggested = (int)Math.Ceiling((currentLeadMs + ping + 200) / 50.0) * 50;
return new Advice(true, suggested,
$"{tooLate} puntate rifiutate perché tardive: conviene allargare l'anticipo a {suggested} ms.");
}
var actual = samples.Select(s => s.ActualMs).OrderBy(v => v).ToList();
var median = actual[actual.Count / 2];
var worst = actual[0]; // la volta che è arrivata più tardi
var avgPing = samples.Average(s => s.PingMs);
// Margine da non erodere: il ritardo di rete più mezzo cuscinetto, e mai
// sotto il pavimento ricavato dai dossier.
var safety = Math.Max(avgPing + 150, SafeFloorMs);
// Si può guadagnare solo ciò che avanza rispetto al caso peggiore osservato.
var headroom = worst - safety;
if (headroom < 150)
return new Advice(false, currentLeadMs,
$"L'anticipo attuale è già vicino al margine di sicurezza (peggior caso {worst:F0} ms, ping medio {avgPing:F0} ms).");
var proposal = (int)Math.Floor((currentLeadMs - headroom) / 50.0) * 50;
proposal = Math.Max(proposal, (int)Math.Ceiling(safety / 50.0) * 50);
if (proposal >= currentLeadMs)
return new Advice(false, currentLeadMs, "L'anticipo attuale è già adeguato.");
return new Advice(true, proposal,
$"Le puntate arrivano in media {median:F0} ms prima della scadenza e mai oltre {worst:F0} ms: " +
$"si può scendere da {currentLeadMs} a {proposal} ms mantenendo il margine di sicurezza.");
}
}
}