52 Commits
Author SHA1 Message Date
Alby96andClaude Fable 5.1 3985935958 Ricalcolo dei prodotti dallo storico, finestra di avanzamento, operazioni sull'apprendimento, barre uguali
Nella scheda Prodotti c'è il pulsante che rilegge tutto lo storico e rifà da capo i
numeri di ogni articolo: aste viste, prezzo minimo, massimo e medio, puntate del
vincitore, vittorie, osservazioni. I limiti scritti a mano e la stellina non cambiano.
Il ricalcolo si costruisce da parte e sostituisce le schede solo alla fine: un
annullamento a metà lascia quelle di prima.

Ogni operazione massiva passa da una finestra di avanzamento (ProgressDialog): titolo,
a che punto si è, barra, conto fatto/totale, tempo passato e Annulla. Modale quando è
questione di secondi, altrimenti si continua a usare l'applicazione: il recupero delle
puntate dei vincitori e lo studio delle aste vanno piano di proposito.

La scheda Apprendimento ha un menu di operazioni: studiare le aste chiuse non ancora
apprese (lo studio all'avvio si ferma dopo qualche minuto, questo va in fondo),
ricostruire il profilo per prodotto e fascia oraria, rifare il bandit dalle decisioni
registrate, azzerare lo sfidante o la calibrazione, ricominciare da capo. Lo studio
delle aste è una sola routine con budget di tempo facoltativo, avanzamento e
annullamento; quello che è già studiato resta studiato.

Le barre in alto di tutte le schede hanno la stessa altezza (stile TabToolbar): passando
da una scheda all'altra i pulsanti restano allo stesso posto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:47:47 +02:00
Alby96andClaude Fable 5.1 9177b31bd5 Due database, interruttore dell'apprendimento, barre a icone; via kill-switch, esportazioni e voci deprecate
Tutto ciò che l'applicazione registra sta ora in due file SQLite in
%LocalAppData%\AutoBidder\Database: autobidder.sqlite per le osservazioni e
esercizio.sqlite per i documenti d'esercizio (aste nel monitor, prodotti, promozioni,
modelli appresi con prefisso ml/) e per i registri applicativo e del riscatto puntate.
Il motore comune è SqliteDatabase; i file JSON e la cartella Apprendimento delle
versioni precedenti vengono importati la prima volta e lasciati dove sono. La cartella
si cambia dalle Impostazioni: al salvataggio si chiede se spostare i file, e si riavvia.
Le cartelle Dati/Statistiche/Registri, i registri su file, le misure dell'anticipo e
le impostazioni morte (LogBids, AutoApplyProductDefaults, NewAuctionLimitsPriority…)
non esistono più.

Il kill-switch se ne va: al suo posto un solo interruttore in barra, accanto ad
Avvia/Osserva/Ferma, che spegne l'apprendimento. Spento, il motore punta solo entro i
limiti dell'utente (prezzo, puntate, budget, fascia oraria, rischio) con l'anticipo
fisso: niente valore atteso, regime, duello, bandit, anticipo adattivo. Le aste vengono
comunque registrate e studiate.

Tre difetti visti osservando il motore dal vivo per un'ora, in Osserva:
- la copertura «Compralo Ora» con V lasciato al listino azzerava la perdita coperta e
  il motore diceva BID a ogni scadenza con P dell'1%: ora vale solo per i prodotti con
  «Valore reale €» scritto, altrimenti la puntata si conta persa e P decide;
- la chiusura di un'asta arrivava due volte (fine, poi rimozione dal monitor) e di
  nuovo al riavvio: modello, profilo, bandit e statistiche per prodotto contavano ogni
  asta due volte. Il monitor la comunica una volta sola, l'apprendimento salta le aste
  già apprese, e le schede prodotto vengono ricostruite una volta dallo storico;
- la pagina delle ricompense veniva scambiata per la pagina di accesso perché porta un
  collegamento a /login.php: prima si guarda se è la pagina delle ricompense.

Interfaccia: la scheda Esporta e tutto il suo codice sono tolti; le esportazioni
chiedono sempre dove salvare. Prodotti, Storico e Apprendimento hanno barre a sole
icone con tooltip che dicono esattamente cosa succede, colorate come il monitor, con
i nuovi pulsanti di pulizia (spegni tutte le stelline, togli i non seguiti, pulizia
completa; seleziona tutte / elimina le selezionate / svuota lo storico). Impostazioni:
sezione Database, Manutenzione con «Elimina tutti i dati (tranne la login)» e
«Impostazioni di fabbrica», esportazione dei registri in testo. Modifiche.txt, il
prompt ormai realizzato, esce dal repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:58:08 +02:00
Alby96andClaude Fable 5.1 66a1d23d87 Nel motivo della decisione stampa la probabilita, non il valore atteso
La riga «valore atteso +11,99 € (P 1.199,10%)» nel registro e nelle decisioni
mostrava il valore atteso al posto di P(senza risposta).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:02:02 +02:00
Alby96andClaude Fable 5.1 7993ca44a0 Database SQLite al posto dei dossier, gestore del rischio, decisioni in shadow, rete neurale, bandit e simulatore
Le aste non si salvano più in un file JSON per asta più tre archivi da tenere allineati:
un solo database SQLite (winsqlite3.dll di Windows via P/Invoke, nessun pacchetto) con
prodotti, aste, puntate, interrogazioni coalescenti, reset con profondità del ciclo,
nostre puntate, decisioni del motore, misure di rete, puntatori, sessioni e contabilità.
Scrittura in coda su un thread dedicato, letture in WAL. Per scelta dell'utente si parte
da zero: il database si riempie man mano che si osservano le aste; i vecchi dossier
restano leggibili per un'importazione futura ma non si scrivono più. Storico, schede,
apprendimento, rigiocata ed esportazioni leggono tutti da lì.

Il documento di progetto (Modifiche.txt) tradotto in C# senza librerie:
- RiskManager: kill-switch (file KILL_SWITCH, anche dalla barra), HALT persistente per
  stop-loss e drawdown, tetto del giorno, aste in gioco insieme, contabilità in euro.
- Theory: valore atteso con fee, spedizione, valore reale per prodotto (nuova colonna
  nella scheda Prodotti) e copertura «Compralo Ora»; null-model; sopravvivenza empirica e
  Kaplan-Meier dai reset; arrivi di Poisson.
- Shadow per asta: in Osserva il cecchino arriva allo stesso istante, registra cosa
  avrebbe fatto e non punta; alla chiusura ogni decisione riceve l'esito, e ShadowReport
  confronta le policy sugli stessi istanti con intervallo di confidenza sul ROI.
- NeuralNet (MLP, Adam) come sfidante del logistico, scelto dal Brier prequenziale;
  ThompsonBandit come seconda policy che impara dagli esiti delle decisioni;
  PennyAuctionEnv con avversari tarati sul database e QLearningAgent conservativo;
  SimulationLab per il confronto fra policy; esportazione CSV delle decisioni con
  reason_detail. Tutto nella scheda Apprendimento.

Ml/LEGGIMI.md riscritto con le avvertenze su termini d'uso, quadro legale e realtà
economica, lo schema del database e lo stato delle milestone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:00:49 +02:00
Alby96andClaude Fable 5.1 8b7c39c9e3 Sostituisci le euristiche a soglia con regime e anticipo adattivi; un solo archivio per asta
Le strategie a numero fisso (calore, ritiro morbido, anti-bot, avversari aggressivi,
puntata probabilistica, velocità del prezzo, esaurimento, concorrenza) e le impostazioni
morte (cadenze di polling, finestra critica, database, log avanzati, suggerimenti
sull'anticipo, schede dettagliate) non ci sono più: rigiocate sui dossier non fermavano
una puntata sbagliata senza fermarne anche di giuste, e i loro numeri andavano tarati a
mano. Restano i paletti dell'utente (tetti, budget, fascia oraria) e il duello.

Al loro posto due componenti che imparano dalla sessione in corso, dentro i paletti:
- CompetitionRegime per asta (Calmo / Sfogo / Sondaggio) guidato dal valore atteso
  appreso: tre negativi di fila e si lascia sfogare gli altri, si rientra con una
  puntata di prova dopo abbastanza cicli buoni, e se viene coperta subito la pazienza
  raddoppia. È il "capire da soli quando tornare a puntare".
- LatencyModel per l'anticipo: margine + p99 della latenza misurata adesso, tenuto fra
  LeadMinMs e LeadMaxMs; una puntata tardiva alza il margine subito, venti in tempo lo
  abbassano piano. Un anticipo scritto a mano su un'asta vince sempre (BidLeadIsManual).

Archiviazione: l'archivio mensile aste-AAAA-MM.jsonl (95 MB) duplicava il riepilogo che
ogni dossier ha già in coda. AuctionDetailStore ora legge testa e coda dei dossier, con
cache: 6349 file in 6 s la prima volta. Pulsanti per svuotare le esportazioni e per
azzerare le statistiche voce per voce (lo storico passa dalla copia di sicurezza).

Scheda Apprendimento: sezione "Autonomia sul momento" con il modello di latenza e il
regime di ogni asta seguita. Ml/LEGGIMI.md descrive l'algoritmo per intero.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:11:49 +02:00
Alby96andClaude Fable 5.1 afb778ea26 Scheda Apprendimento: cosa sa il modello, cosa ha deciso, e la valutazione dall'app
Una scheda nuova nella barra laterale, fra Esporta e Impostazioni, che legge
direttamente dal servizio e si aggiorna da sola ogni cinque secondi mentre e'
visibile: nessun altro pezzo dell'applicazione sa che esiste.

Mostra: lo stato (aste apprese, esempi visti, aste nel profilo, fattore di
calibrazione, soglia per decidere, se lo studio dei dossier e' in corso); i pesi
dal piu' forte con il verso dell'effetto, perche' le variabili sono a fasce e il
modello e' lineare proprio per poterli leggere; le ultime decisioni date al
motore, con probabilita', valore atteso ed esito (fermata, passa, o solo parere
se il modello non aveva ancora appreso abbastanza); il profilo per prodotto e
fascia oraria; e l'ultima valutazione.

La valutazione si lancia dalla scheda, in sottofondo e annullabile. La logica e'
uscita dal test ed e' entrata in LearningEvaluator, condivisa fra il target da
riga di comando e la scheda: i numeri che vede l'utente sono gli stessi che vede
chi sviluppa. "Ricomincia da capo" butta modello, profilo ed elenco dei dossier
letti e ristudia tutto l'archivio, con una conferma prima.

Le decisioni si tengono in una coda corta; lo stesso giro di un'asta ne
produrrebbe molte uguali di fila, quindi si registra solo il cambiamento.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 23:09:03 +02:00
Alby96andClaude Fable 5.1 46134c822d Apprendimento sempre acceso: impara da ogni asta chiusa e decide col valore atteso
Via le due modalita' Addestramento/Gara, per scelta dell'utente: la misura aveva
detto che il lavoro di registrazione costa mezzo punto di un core, e allora tanto
vale registrare sempre. Resta la coalescenza verso l'interfaccia, che era la
correzione vera.

COSA IMPARA. Per ogni puntata di ogni asta chiusa: e' rimasta senza risposta?
E' l'unica etichetta abbondante — 1,8 milioni di esempi sui dossier locali —
contro le diciannove aste vinte dall'utente, che non farebbero un modello. Il
comportamento avversario non dipende da chi ha puntato, e questo rende
utilizzabili le puntate altrui. Regressione logistica online scritta a mano,
senza librerie, in Ml/: un passo di gradiente per esempio, aggiornata a ogni
chiusura, salvata subito. Variabili a fasce: durata del ciclo, quota di
autopuntate recenti, puntatori distinti, prezzo sul valore, fascia oraria (le
fasce ricalcano la curva misurata sulle chiusure), feriale/festivo, ora di
aggancio, tipo di puntata, profondita' dell'asta, stesso puntatore di due fa,
prodotto in 32 cassetti con hash stabile. In parallelo un profilo per prodotto x
fascia oraria x tipo di giorno, con restringimento verso il prodotto e poi verso
il globale dove i dati scarseggiano.

COME DECIDE. In ShouldPlaceBid: probabilita' appresa per il margine residuo meno
il costo della puntata; sotto zero non si punta, e il registro riporta i numeri.
Il modello parla sempre ma decide solo dopo 300 aste apprese. Le previsioni sono
calibrate in linea con il rapporto osservato/previsto sugli ultimi 20.000 esempi.

QUANTO VALE, MISURATO. Valutazione prequenziale sui 3000 dossier piu' recenti
(ogni asta prima prevista poi appresa, come nell'uso): sollevamento 10,8x nella
cima dell'1%, 5,8x al 5%; calibrazione entro il 10-30% in ogni fascia. Sulle 116
puntate vere dell'utente di agosto: nessuna era finale, e il cancello ne avrebbe
fermate 45 senza perderne una vincente. La valutazione congelata (addestra sui
vecchi, giudica sui nuovi) sovrastima di 1,5-1,8x per deriva del mercato, non per
passo o regolarizzazione: provati entrambi. Griglia e prequenziale stanno nel
test MlModelBacktest, target Apprendimento, attivita' VS Code "valuta
apprendimento".

PRIMO ADDESTRAMENTO. In sottofondo, dal piu' recente, con un tempo massimo per
avvio. Il lettore salta le righe di poll prima del JSON: 6289 dossier per 8,8 GB
in 64 secondi, verificato dal vivo, archivio completo al primo avvio.

ACQUISIZIONE. I poll nel dossier si scrivono solo quando cambia qualcosa di
visibile, e comunque uno al secondo: -88,8% misurato, senza perdere la
profondita' di nessun ciclo. Il reset porta il minimo del cronometro e la durata
del ciclo chiuso. Via le righe di log che copiavano i reset parola per parola.
Corretto un difetto vecchio: nessun dossier marcava le puntate nostre, perche' il
parser lo faceva solo se riceveva il nome utente e nel percorso dei poll non gli
arrivava. Ora si marcano in MergeBidHistory e l'intestazione porta il nome
utente; per i dossier vecchi il lettore accetta il nome da fuori.

Trovato dal vivo e corretto: salvataggi concorrenti all'avvio quando piu' aste
chiudono insieme. Il modello salvato non rilegge piu' i parametri (passo,
regolarizzazione): sono scelte del codice, e un cambio vale dal riavvio dopo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:45:49 +02:00
Alby96andClaude Fable 5.1 08c5023f77 Documenti per chi installa da zero, pulizia per prodotto ed esborso, modalita' Gara
Tre richieste, piu' la causa vera della lentezza trovata misurando.

PERCORSI. Le impostazioni e i dati vanno in Documenti\AutoBidder per chi installa
da zero; sessione, riferimenti del sito e profilo del browser restano in
%LocalAppData%, perche' la sessione e' protetta con DPAPI e legata all'utente
Windows. Chi ha gia' un'installazione non viene spostato: se le impostazioni
esistono in %LocalAppData% e non in Documenti si continua a usare quelle dove
sono (scelta esplicita dell'utente, per non toccare mesi di raccolta). La radice
si decide una volta sola all'avvio e non cambia piu' nel processo. La suite di
test la dirotta in una cartella temporanea: senza, un test che tocca le
impostazioni leggerebbe quelle vere.

PULIZIA. Tre criteri nuovi: per prodotto (elenco con i conteggi, confronto senza
maiuscole), solo osservate / con mie puntate (i due versi: storico compatto delle
proprie partite, o storico "di mercato" senza le proprie mosse), prezzo finale
oltre una percentuale del valore (le chiusure incaponite spostano medie e limiti
consigliati verso l'alto per tutti; senza valore noto l'asta resta, non si
indovina). Dopo ogni pulizia le statistiche per prodotto si ricostruiscono da
zero dallo storico rimasto: sono un archivio a parte alimentato asta per asta, e
avrebbero continuato a descrivere aste che non ci sono piu'.

MODALITA'. Addestramento registra tutto; Gara toglie i poll grezzi nel dossier,
le righe informative del registro dell'asta e il dettaglio esteso. Letta una
volta all'avvio e immutabile fino alla chiusura, come chiesto: la scheda mostra
la modalita' in vigore e avvisa del riavvio solo se la scelta differisce.

Misurato prima di promettere: il lavoro che Gara evita costa 4,9 microsecondi
per poll. A 57 aste per 17 poll al secondo sono 4,7 ms di CPU al secondo, mezzo
punto di un core. La scrittura su disco era gia' asincrona. Gara fa bene al disco
e ai dossier, non alla velocita'.

LA LENTEZZA VERA. Ogni poll arrivava al thread dell'interfaccia: un
BeginInvoke, una scansione lineare dei ViewModel e venti notifiche di
proprieta'. Con trenta aste diecimila notifiche al secondo, con cinquantasette
quasi ventimila. Adesso si manda all'interfaccia solo il poll in cui cambia
qualcosa di visibile (prezzo, puntatore, stato, secondo intero, se e' mio), e
comunque uno al secondo per asta perche' ping e contatori si rinfreschino. Il
conto alla rovescia scorre gia' da solo con il battito da 200 ms. Il motore non
e' toccato: la decisione di puntare vede ogni poll come prima.

Misurato su 40 dossier veri, 146.138 poll: ne passano 16.375, l'88,8% viene
scartato. Nove volte meno lavoro sul thread dell'interfaccia.

Nella scheda Impostazioni entrano anche la fascia oraria e la pulizia
dell'elenco, che il motore usava gia' ma che non si potevano ancora cambiare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 18:39:19 +02:00
Alby96andClaude Opus 5 4e835e220a Togli da sole le aste concluse, ma trattieni quelle costate qualcosa
Con l'aggiunta automatica accesa le aste concluse si accumulano a centinaia e
seppelliscono quelle vive, che e' il contrario di quello che serve guardando un
cruscotto. Il pulsante manuale "rimuovi le aste concluse" c'era gia', ma toglieva
tutto senza distinguere.

La rimozione automatica e' accesa di serie e toglie solo le aste che non hanno
piu' niente da dire. Restano invece, ciascuna con la sua opzione:

- quelle su cui si e' puntato, perche' sono le uniche che vale la pena riguardare
  e i soldi erano veri;
- quelle vinte, perche' c'e' da confermare l'acquisto su Bidoo e una vittoria
  sparita dall'elenco e' il modo piu' semplice di dimenticarsene;
- quelle di cui non si e' vista la fine, perche' sono esattamente quelle da
  controllare.

L'ordine dei controlli conta: prima le ragioni per trattenere, cosi' il motivo
scritto nel registro e' il piu' importante e non l'ultimo incontrato. Una vinta su
cui si e' anche puntato resta "vinta", che e' quella che richiede un'azione. Il
motivo finisce nel registro anche quando l'asta resta: senza, un'asta che sparisce
e una che non sparisce sarebbero entrambe inspiegabili.

La rimozione avviene come ultimo passo, dopo che storico, dossier e statistiche
del prodotto sono gia' scritti: a quel punto togliere l'asta dall'elenco non perde
niente.

Corretto anche un difetto introdotto ieri con la fascia oraria: la rigiocata sui
dossier usava le impostazioni vere, fascia compresa, e la fascia guarda l'orologio
di parete. Una rigiocata ripercorre aste gia' concluse e l'ora in cui la si lancia
non c'entra nulla con l'ora in cui quelle aste correvano: lanciata di notte
avrebbe rifiutato ogni puntata e riportato zero, che e' il tipo di risultato che
sembra vero e non lo e'. Adesso la rigiocata lavora su una copia delle
impostazioni con la fascia spenta, senza mutare quelle dell'utente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:39:00 +02:00
Alby96andClaude Opus 5 480e423124 Togli la pubblicazione dalla obj/ condivisa: rompeva la compilazione di debug
Sintomo: una raffica di CS2001 "file di origine MainWindow.g.cs non trovato" su
otto file .g.cs, lanciando la normale compilazione di debug da VS Code, senza che
il codice fosse cambiato. Il classico guasto intermittente della compilazione WPF.

Causa: il target Pubblica usava la sola opzione -o, che sposta il risultato ma
lascia gli INTERMEDI nella obj/ del progetto, cioe' la stessa cartella che usa la
compilazione di debug. La compilazione WPF genera un progetto temporaneo
_wpftmp.csproj a ogni giro, e quando ci trova dentro lo stato di un'altra
configurazione smette di produrre le classi parziali dello XAML. Bastava
pubblicare mentre si compilava in VS Code, o subito prima, per rompere tutto.

Era gia' il rimedio adottato per i test, dove artifacts-path era stato messo
proprio per questo; il target Pubblica era rimasto indietro. Le due opzioni
convivono: artifacts-path sposta gli intermedi, -o continua a decidere dove
finisce l'eseguibile.

Verificato ripetendo tre volte la sequenza che rompeva - pubblica, poi compila in
debug - con la obj/ svuotata all'inizio: zero errori tutte e tre le volte, e la
pubblicazione non crea piu' obj/Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:27:40 +02:00
Alby96andClaude Opus 5 48096c7fc7 Porta avanti le impostazioni vecchie, o le correzioni di oggi non arrivano
Trovato provando davvero l'applicazione, non dai test. Il settings.json esistente
conteneva MaxRequestsPerSecond 40 e DefaultBidBeforeDeadlineMs 1000, e il valore
su disco vince sempre sui predefiniti: installando la versione nuova, le due
correzioni che pesano di piu' - tetto delle richieste tolto e anticipo a mezzo
secondo - sarebbero state annullate dal file dell'utente, che avrebbe concluso
che non funzionano.

Che il file vinca e' giusto: sono scelte di chi usa il programma. Ma quei due
valori non erano scelte, erano conseguenze di misure sbagliate. Erano stati
tarati quando il tetto stesso accodava le chiamate e gonfiava il ping a 444 ms al
p99,9; senza tetto il ping misurato sta fra 47 e 78 ms.

Migrazione una tantum, con un numero di schema nel file perche' giri una volta
sola. Tocca solo chi ha ancora esattamente il vecchio valore: chi lo aveva gia'
cambiato di suo se lo tiene, perche' quella e' una scelta e non spetta a un
aggiornamento ribaltarla. Il salvataggio avviene dentro Load, che di norma non
tocca il disco, ma una migrazione capita una volta nella vita del file e
lasciarla non salvata la farebbe ripetere a ogni avvio.

Verificato dal vivo sull'eseguibile pubblicato: 40 diventa 0, 1000 diventa 500,
lo schema passa da assente a 1, e le impostazioni nuove (fascia oraria, ritiro
dai duelli) compaiono coi valori giusti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:16:54 +02:00
Alby96andClaude Opus 5 8b9691d692 Sospendi le puntate nella fascia oraria in cui costano il doppio
Su 6060 aste concluse con prezzo e valore noti, la stessa asta non costa uguale a
tutte le ore. Prezzo di chiusura mediano in percentuale del valore del prodotto:
4,6% alle 12 e 5,3% fra le 10 e le 13, contro 8,6% a mezzanotte e 9,7% alle 9. Le
puntate necessarie per vincere seguono la stessa curva: 21-24 nelle ore buone,
26-29 in quelle cattive. Quasi il doppio del costo per lo stesso oggetto.

Il motivo e' che di notte Bidoo sospende le puntate: restano poche aste in corso e
la concorrenza si concentra li'. Le ore di confine, mezzanotte e le nove, sono le
peggiori, perche' c'e' ancora qualcosa da giocare e ci sono tutti addosso.

La fascia predefinita e' 00:00-10:00. Sospende le puntate, NON il monitoraggio:
l'asta resta in stato Attiva, continua a essere seguita e riprende a puntare da
sola alla fine della fascia. E' voluto: le aste lunghe attraversano la fascia, e
fermarle davvero significherebbe perderle invece che risparmiare. Ogni puntata
saltata scrive nel registro il motivo e l'orario di ripresa.

Tre livelli di override, dal piu' specifico al piu' generale: la singola asta
scavalca il prodotto, il prodotto scavalca le impostazioni generali. Un null non
vuol dire "no" ma "non ho un'opinione", ed e' la differenza che permette di
spegnere il filtro su una sola asta senza toccare il resto.

Una fascia a cavallo di mezzanotte (22-6) funziona; inizio uguale a fine vuol dire
filtro spento, che e' l'unica lettura sensata visto che l'alternativa sarebbe non
puntare mai.

Backtest della regola sul duello, completato su tutte le soglie. Sui dossier veri
in cui il bot ha puntato: 13 aste, 366 puntate, zero vittorie. Con soglia 5 le
puntate sarebbero state 49, risparmiate 317 (86,6%), nessuna vittoria persa. Il
risultato tiene da soglia 2 a soglia 10, che e' quanto ci si puo' aspettare da un
campione in cui non si era vinto niente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:50:06 +02:00
Alby96andClaude Opus 5 5bac29b0af Densita' della griglia e suggerimento dell'anticipo allineato ai dati nuovi
Il pannello della singola asta consigliava di tenere l'anticipo fra 600 e 2000 ms
e proponeva 800: numeri nati da misure fatte quando il tetto di 40 richieste al
secondo accodava le chiamate e gonfiava il ping. Adesso dice quello che i dati
dicono davvero: tre quarti delle puntate avversarie sono automatiche e scattano a
2 secondi, sotto quella soglia resta solo chi punta a mano, e il predefinito e'
500 ms perche' e' circa nove volte il ping misurato.

Righe della griglia da 28 a 22 px con carattere piccolo: su un cruscotto che
serve a tenere d'occhio molte aste insieme l'altezza e' la risorsa scarsa, e sei
pixel di aria per riga costano una decina di aste sullo schermo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:31:36 +02:00
Alby96andClaude Opus 5 9b94e2d405 Ritirati dai duelli con l'autopuntata, e punta a mezzo secondo
La strategia A, dai numeri dei dossier: 5410 aste, 2,08 milioni di puntate.

Bidoo dichiara per ogni puntata se e' automatica o manuale. Le automatiche sono
il 75,3% del totale ma vincono il 46,0% delle aste; le manuali sono il 24,7% e ne
vincono il 53,9%. Una puntata manuale vale 3,6 volte una automatica: 178 puntate
per vittoria contro 634.

Il meccanismo del duello e' stato ricostruito esattamente. L'intervallo mediano
fra due puntate consecutive e' 6,0 s su 2.799.681 intervalli: il timer si azzera
a ~8 s e l'autopuntata scatta a 2 s, quindi 8-2=6. Il ciclo da 13,0 s osservato
nelle prove reali era la somma di 7 s di attesa nostra e 6 s della sua risposta.
Puntare tardi non evita l'autopuntata avversaria: la sveglia, perche' si riarma
quando il suo padrone perde la testa della classifica, non a un orario fisso.

Contro una macchina cosi' non esiste attrito vincibile. Adesso il motore conta le
risposte con la firma dei 6+/-2,5 s e dopo cinque di fila smette di puntare su
quell'asta. Una risposta fuori firma azzera il conteggio invece di scalarlo:
l'errore costoso e' ritirarsi da un'asta che si poteva vincere, non spendere
cinque puntate di troppo.

Rigiocato sui dossier veri in cui il bot ha puntato: 366 puntate spese, 13 aste,
zero vittorie. Con la regola a soglia 3 sarebbero state 31 puntate, 335
risparmiate (91,5%), nessuna vittoria persa.

L'anticipo passa da 1000 a 500 ms. Le due ragioni per stare larghi sono cadute:
il p99,9 del ping a 444 ms era misurato col tetto di 40 richieste al secondo che
accodava le chiamate, e senza tetto il ping sta fra 47 e 78 ms; e la concorrenza
sotto i due secondi non e' fitta ma rada, visto che tre quarti degli avversari
sono autopuntate che hanno gia' sparato.

Il tipo di puntata non viene piu' inventato. In due punti il motore ricostruiva
una puntata da un cambio di prezzo fra due interrogazioni e la marcava "Auto"
comunque: adesso quelle portano un trattino, perche' il tipo non lo ha detto
nessuno. La colonna in cronologia mostra quindi solo tipi dichiarati dal server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:20:34 +02:00
Alby96andClaude Opus 5 20434ca273 Riscatta aprendo il collegamento, e fai scorrere il conto alla rovescia
Due difetti che i registri di tre giorni mostravano chiaramente.

Il riscatto dei buoni giudicava la risposta del sito, e la giudicava male: ogni
singolo tentativo risultava "sessione scaduta: risposta la pagina di accesso" —
6 trovati, 0 riscossi, 6 non riusciti, giro dopo giro per tre giorni — mentre le
puntate arrivavano lo stesso. Il riconoscimento della pagina di accesso scattava
su pagine che di accesso non erano, e il servizio dichiarava di non funzionare
mentre funzionava.

Adesso aprire il collegamento e' il riscatto: si apre, si scrive che e' stato
aperto, e il numero vero lo da' la differenza di saldo, che e' l'unica misura che
non mente. Resta un solo fallimento possibile, l'errore di trasporto, dove il
collegamento non e' stato aperto affatto. Un premio si riscuote una volta sola:
fra i due errori, insistere su un premio gia' preso non costa niente, mentre
dichiarare falliti dei riscatti riusciti riempie il registro di allarmi inutili.
Il giudizio vecchio non e' stato cancellato, e' in GiudicaRisposta.

Il conto alla rovescia in griglia restava fermo fra un poll e l'altro. Il valore
era gia' giusto — si ricava dalla scadenza ancorata all'orologio del server, non
dall'ultimo poll — ma nessuno lo richiedeva finche' non arrivava una risposta, e
nelle prove reali fra un poll e l'altro passava anche un secondo e mezzo:
sembrava un'applicazione bloccata. Un battito da cinque volte al secondo notifica
ora le tre sole proprieta' che dipendono dal tempo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:29:05 +02:00
Alby96andClaude Opus 5 feb39ee2de Togli il tetto alle richieste e l'attesa fra i poll: era il collo di bottiglia
Misurato sui registri di tre giorni di prove reali (886 dossier, 4,86 milioni di
interrogazioni). Il tetto globale di 40 richieste al secondo veniva spartito fra
tutte le aste seguite, fino a 31 insieme: la distribuzione risultava tagliata
netta sul limite (p90 39, p99 41, saturo nell'8% dei secondi) e ogni singola asta
finiva interrogata ogni ~596 ms invece dei 220 configurati per la finestra
critica. Con il timer a un secondo restava un solo sguardo prima della scadenza,
e la puntata partiva su una stima della scadenza vecchia di mezzo secondo.

Le quattro soglie di cadenza (lontano/medio/vicino/critico) non descrivevano
quindi niente di reale: qualunque numero ci si scrivesse, il tetto decideva al
posto loro. Adesso non c'e' nessuna attesa fra un poll e il successivo: si
riparte appena la risposta e' arrivata, cioe' ~17 interrogazioni al secondo per
asta con il ping misurato di 57 ms. Resta la sola cadenza allargata per le aste
non ancora cominciate, che non e' una scelta di ritmo: fino all'apertura il
server non ha nulla di diverso da dire.

Senza tetto fisso serviva pero' qualcosa che reagisse, altrimenti l'unico segnale
di "troppe richieste" sarebbe stato un blocco dell'account. Il freno ora lo detta
il server: un 429 o un 503 mette in pausa il polling per un secondo, raddoppiando
fino a otto se insistono, e la pausa si azzera al primo giro riuscito. Le puntate
hanno priorita' Critical e non passano dal freno: se il momento giusto e' adesso,
una pausa preventiva costerebbe l'asta.

Il tetto resta configurabile per chi lo vuole, con 0 = nessun limite come nuovo
predefinito.

Scelta dell'utente, dopo avergli mostrato il rischio: niente tetto, il massimo
che regge la rete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 18:28:02 +02:00
Alby96andClaude Opus 5 1d578debe2 Togli dal percorso caldo la rilettura dell'intero storico
Sei modifiche chieste in questa sessione, di cui una pesa piu' di tutte le altre.

CompletedAuctionsStore rileggeva e rideserializzava l'intero file a ogni chiamata:
misurati 72 ms su 5876 record, 3,9 MB. Append pagava quel prezzo piu' la
riserializzazione e la riscrittura completa per cambiare un record solo, 120 ms
in tutto, e la chiamata arriva da un gestore di eventi di MainWindow, quindi dal
thread dell'interfaccia, che restava fermo a ogni asta conclusa. Il recupero
delle puntate dei vincitori lo faceva in un ciclo: 4675 aste erano 7,2 minuti di
solo rimescolamento JSON e 17,8 GB scritti sul disco.

Adesso c'e' una copia in memoria invalidata sulla data di modifica del file, cosi'
resta giusta anche se il file viene sostituito da fuori (ripristino di un backup),
e un AppendMany che applica molti record con un salvataggio solo, con un indice
per id perche' il caso per cui esiste non torni quadratico. Il recupero salva a
blocchi di cinquanta e deposita comunque quello che ha in mano quando esce, anche
per annullamento: le richieste gia' fatte sono state pagate al server.

Misurato sullo stesso file: 95 volte piu' veloce, 7,2 minuti diventano 4,5
secondi, 17,8 GB diventano 0,36 GB.

Le altre cinque:

- Il setup si chiama AutoBidder_<versione>.exe.
- I pulsanti di stop sono rossi, sia quello globale sia quelli per asta nella
  griglia: erano gli unici tre senza colore mentre avvia e osserva ce l'avevano.
- La pulizia dello storico sa togliere le aste seguite solo in parte. Non serve
  nessun campo nuovo: il prezzo finale in centesimi e' il totale delle puntate
  dell'asta, quindi il rapporto con i reset osservati dice quanta asta si e'
  vista, e la regola vale anche sullo storico gia' raccolto. Verificata sui dati
  veri: su 5876 aste nessuna supera copertura 1,02, che e' quanto ci si aspetta
  se l'identita' e' giusta.
- La griglia ricicla i contenitori di riga invece di ricrearli.
- Il costo puntata gia' esisteva col predefinito giusto: al suggerimento e' stato
  aggiunto perche' 0,20 e' il numero da tenere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:48:54 +02:00
Alby96andClaude Opus 5 5adc4a6526 Passa le note di rilascio dall'ambiente: -p: le spezzava sulle virgole
MSBuild divide il valore di una proprieta' sulle virgole: -p:Note=uno, due
diventa la proprieta' Note=uno piu' un'opzione ` due`, e si finisce su MSB1006
`proprieta' non valida`. Una nota di rilascio in italiano contiene quasi sempre
una virgola, quindi l'attivita' di rilascio si sarebbe rotta al primo uso reale
del prompt delle note.

Le note arrivano ora in AUTOBIDDER_NOTE, che MSBuild legge come proprieta' senza
passare dal parsing della riga di comando. Provato con virgole, apostrofi, due
punti e virgolette: arrivano integri. -p:Note= resta accettato per chi lo passa
a mano senza virgole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:02:18 +02:00
Alby96andClaude Opus 5 66e3af043c Riformatta tasks.json come lo scrive il formattatore di VS Code
Solo impaginazione: gli oggetti group e presentation passano da una riga a piu'
righe. Nessuna differenza di contenuto (git diff --ignore-all-space e' vuoto).
Committato per non lasciare l'albero sporco a ogni salvataggio del file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:00:08 +02:00
Alby96 9a632b8d62 Aggiungi un elenco di cose da fare nel file Modifiche.txt 2026-08-04 22:51:35 +02:00
Alby96andClaude Opus 5 cb0e838964 Rifiuta il rilascio se il tag non e' arrivato sul remoto
Il push dei tag non porta con se' i commit: a ramo indietro il tag punta a un
oggetto che il remoto non conosce e viene rifiutato. La creazione della release
pero' non falliva — Gitea, non trovando il tag, lo crea da se' sulla testa del
ramo predefinito. Sarebbe stata pubblicata una release che dichiara un commit e
allega il codice di un altro, senza che niente lo segnalasse.

Adesso il tag viene riletto dal remoto dopo il push e il rilascio si ferma se
non c'e', dicendo di spingere il ramo. I commit restano da spingere a mano:
quando farlo lo decide chi lavora, non la catena di rilascio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:45:48 +02:00
Alby96andClaude Opus 5 8954f9aaba Correggi i commenti che indicavano ancora il .csproj come fonte della versione
AppInfo e il .csproj dicevano entrambi di alzare <Version> per pubblicare. Da
quando la versione viene dal tag non e' piu' vero, ed e' il tipo di commento che
fa perdere tempo fra sei mesi: adesso dicono a cosa serve davvero quel numero
(le compilazioni di sviluppo) e dove sta la fonte dei rilasci.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:41:55 +02:00
Alby96andClaude Opus 5 94843311b4 Il tag git diventa la fonte unica della versione
Il .csproj non viene piu' riscritto a ogni rilascio: i quattro numeri di
versione arrivano a dotnet publish come proprieta' da riga di comando, ricavati
dal tag su HEAD. Tag, eseguibile, installatore e release portano lo stesso
numero per costruzione invece che per disciplina, e un rilascio non lascia piu'
una modifica al progetto da committare dopo il fatto.

Se HEAD non ha un tag la catena lo crea da se' — numero chiesto o minor
successiva alla piu' alta esistente — ma lo fa in fondo, a installatore
prodotto: un giro fallito non lascia dietro il tag di una versione mai
costruita. Rifiuta di taggare un albero con modifiche non committate, perche'
sarebbe un tag che non indica niente di ricostruibile.

La configurazione di Gitea viene letta prima di compilare: senza token ci si
ferma in due secondi invece che dopo tre minuti di build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:38:45 +02:00
Alby96andClaude Opus 5 52e5f68da0 Completa .gitignore con le regole del vecchio repository
.idea/, Thumbs.db e Desktop.ini erano coperte nella cartella BidooBidder ma
non qui. Aggiunte prima di cancellarla, cosi' non si perde nulla.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:17:28 +02:00
Alby96andClaude Opus 5 99b3030180 Porta la catena di rilascio nel repository ufficiale
Il progetto viveva in una cartella senza alcun commit: tutto il lavoro stava
solo sul disco. Trasferito qui, dove esiste una storia e un remoto.

Mancava soltanto build/: i sorgenti erano gia' allineati e identici. Nessun
percorso e' stato toccato — Release.proj usa MSBuildThisFileDirectory e
tasks.json usa ${workspaceFolder}, quindi il trasloco non li riguarda.

- build/Release.proj: verifica, backtest, pacchetto e rilascio in un solo file
  MSBuild, senza script. Provato dalla nuova posizione: 287 test verdi,
  installatore prodotto, backtest eseguito.
- build/gitea.example.json: url, owner e repo veri; resta da mettere il token
  in build/gitea.json, che e' ignorato da git.
- build/sostituiti/: i quattro .ps1 rimpiazzati da Release.proj. Entrano qui
  solo per non perderli con la vecchia cartella: da ora sono recuperabili
  dalla storia e si possono cancellare.
- .gitignore: gitea.json (contiene una credenziale), i riepiloghi del backtest
  e i pacchetti in bin/installer.

Versione 4.12.0 -> 4.13.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:15:53 +02:00
Alby96 7ca504a70a 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.
2026-08-04 21:49:53 +02:00
Alby96 551697d98d Aggiunta validazione robusta per campi numerici
Implementata una nuova funzionalità per garantire che tutti i campi numerici accettino solo input validi.
- Introdotta la classe helper `NumericTextBoxHelper` per configurare campi interi e decimali.
- Gestiti input non validi, campi vuoti e normalizzazione dei decimali.
- Applicata validazione a 13 campi numerici in tutta l'applicazione.
- Aggiunto supporto per tastiere internazionali (punto e virgola).
- Aggiornati `MainWindow.xaml.cs`, `CHANGELOG.md` e documentazione.
- Definiti test e casi d'uso per verificare il corretto funzionamento.
2025-11-27 17:29:09 +01:00
Alby96 3db0d946b7 Aggiunti riordinamento e navigazione aste migliorati
- Introdotti pulsanti "Sposta Su" e "Sposta Giù" per il
  riordinamento manuale delle aste nella lista.
- Implementata navigazione con frecce direzionali nella griglia,
  con aggiornamento automatico dei dettagli e scroll.
- Salvato automaticamente l'ordine delle aste su disco.
- Risolto conflitto con GridSplitter per le frecce direzionali.
- Aggiunti log dettagliati per le operazioni di riordinamento.
- Gestiti casi limite (es. asta già in cima o in fondo).
- Migliorata UX con pulsanti chiari e tooltip informativi.
- Aggiornati documentazione e changelog per riflettere le
  modifiche.
2025-11-27 15:47:58 +01:00
Alby96 d08e54657a Migliora gestione visualizzazione latenza asta
- Modificato il binding della colonna "Latenza" in `AuctionMonitorControl.xaml` per utilizzare la nuova proprietà `LatencyDisplay`.
- Aggiunta la proprietà `LatencyDisplay` in `AuctionViewModel.cs` per calcolare dinamicamente la latenza in base allo stato dell'asta.
- Aggiunte notifiche di cambiamento per `LatencyDisplay` per aggiornare correttamente l'interfaccia utente.
- Documentata la proprietà `LatencyDisplay` con commenti XML per migliorarne la comprensione.
2025-11-27 12:42:20 +01:00
Alby96 b810c7f76b Rimuove colonna "Resets" da AuctionMonitorControl.xaml
La colonna "Resets" è stata rimossa dal `DataGrid` in
`AuctionMonitorControl.xaml`. Era legata alla proprietà
`ResetCount` e aveva una larghezza di 60. Questa modifica
semplifica l'interfaccia utente, eliminando un'informazione
non più necessaria.
2025-11-27 12:29:40 +01:00
Alby96 95018e0d65 Aggiunto HtmlCacheService per caching e rate limiting
Introdotto un servizio centralizzato (`HtmlCacheService`) per gestire richieste HTTP con:
- Cache HTML (5 minuti) per ridurre richieste duplicate.
- Rate limiting (max 5 richieste/sec) e concorrenza limitata (3 richieste parallele).
- Retry automatico (max 2 tentativi) e timeout configurabile (15s).
- Logging dettagliato per cache hit, retry e richieste fallite.

Aggiornati i metodi di caricamento dei nomi e delle informazioni prodotto per utilizzare il nuovo servizio, migliorando caching, gestione degli errori e decodifica delle entità HTML.

Aggiunto supporto per il recupero automatico dei nomi generici delle aste e un timer per la pulizia periodica della cache.

Documentato il servizio in `FEATURE_HTML_CACHE_SERVICE.md`. Correzioni minori e miglioramenti alla leggibilità del codice.
2025-11-27 12:24:09 +01:00
Alby96 df9b63dd41 Aggiunta opzione "Ricorda Stato" per le aste salvate
È stata introdotta l'opzione "Ricorda Stato" che consente di ripristinare lo stato esatto (attiva, in pausa, ferma) di ogni asta salvata al caricamento.

- Aggiunto il `RadioButton` "Ricorda Stato" in `SettingsControl.xaml`.
- Gestita la nuova opzione in `MainWindow.EventHandlers.Settings.cs`.
- Aggiornata la logica di caricamento in `AuctionManagement.cs` per supportare "Ricorda Stato".
- Introdotto il salvataggio automatico dello stato delle aste in `ButtonHandlers.cs` e `Commands.cs`.
- Migliorati i log per riflettere il comportamento del sistema.
- Aggiunta la proprietà `RememberAuctionStates` in `SettingsManager` con valore predefinito `false`.
- Migliorata la leggibilità e manutenibilità del codice.

Queste modifiche migliorano la flessibilità e l'esperienza utente, garantendo coerenza dei dati e maggiore chiarezza nei log.
2025-11-26 20:52:48 +01:00
Alby96 7a01251258 Refactoring layout Header AuctionMonitorControl.xaml
È stato aggiornato il layout del `Header` per passare da una struttura compatta su 2 righe a una su 3 righe.

- Aggiunta una nuova `RowDefinition` per gestire la terza riga.
- Modificato il `Padding` del `Border` da `15,10` a `15,8`.
- Aggiornata `Riga 1` per mostrare solo le `Puntate` con margine ridotto.
- Aggiunta `Riga 2` per visualizzare il `Credito Shop` con margini e font ridotti.
- Aggiornata `Riga 3` per mostrare solo le `Aste vinte da confermare`.
- Esteso lo `StackPanel` dei pulsanti di controllo per coprire tutte e 3 le righe.
- Migliorata la leggibilità dei commenti relativi agli indicatori di limite minimo.
2025-11-26 11:58:20 +01:00
Alby96 56484e0bec Aggiunto pulsante "Rimuovi Tutte" e miglioramenti UI
- Aggiunto il pulsante "Rimuovi Tutte" in `AuctionMonitorControl.xaml` per eliminare tutte le aste monitorate.
- Implementato il metodo `RemoveAllButton_Click` in `AuctionMonitorControl.xaml.cs` e registrato il nuovo evento routed `RemoveAllClickedEvent`.
- Aggiunto il gestore `AuctionMonitor_RemoveAllClicked` in `MainWindow.ControlEvents.cs` e collegato l'evento in `MainWindow.xaml`.
- Migliorata la gestione degli errori e aggiunti messaggi di conferma dettagliati.
- Introdotti nuovi metodi di utilità per resettare le impostazioni, pulire la lista utenti e il log di un'asta selezionata.
- Rimosso codice obsoleto per semplificare la base di codice.
2025-11-26 11:37:11 +01:00
Alby96 c199e542ba Aggiunto limite configurabile storia puntate
Introdotta la possibilità di configurare un limite massimo di puntate visualizzabili nella scheda "Storia Puntate" tramite l'interfaccia utente. La nuova proprietà `MaxBidHistoryEntries` è stata aggiunta alle impostazioni e salvata in modo persistente.

- Aggiunti controlli UI per configurare il limite.
- Implementata persistenza della lista `RecentBids` con serializzazione JSON.
- Introdotto il metodo `MergeBidHistory` per unire puntate evitando duplicati e mantenendo ordine cronologico decrescente.
- Sincronizzate le statistiche utenti (`BidderStats`) con la lista `RecentBids`.
- Ripristinata la proprietà `IsMyBid` al caricamento delle aste salvate.
- Ottimizzate le performance con `HashSet` per deduplicazione e limite configurabile.
- Creato il file `FIX_BID_HISTORY_PERSISTENCE.md` per documentare il problema e la soluzione.
- Garantita compatibilità retroattiva con aste salvate.

Questi aggiornamenti migliorano la gestione, la visualizzazione e la persistenza della storia delle puntate, offrendo un'esperienza utente più robusta e intuitiva.
2025-11-26 10:44:04 +01:00
Alby96 d99b5ec923 Aggiunta scheda "Storia Puntate" con aggiornamento live
Introdotta una nuova scheda "Storia Puntate" nel pannello
dell'asta selezionata, che mostra la cronologia delle ultime
puntate in tempo reale. La scheda utilizza un `TabControl`
con due `TabItem`: uno per gli utenti e uno per la storia
delle puntate.

- Creata la classe `BidHistoryEntry` per rappresentare una
  singola puntata, con proprietà come `Price`, `BidType`,
  `Timestamp`, e calcoli formattati.
- Aggiunte proprietà `RecentBids` in `AuctionInfo` e
  `RecentBidsHistory` in `AuctionState` per gestire i dati
  della cronologia.
- Modificato il parsing API in `BidooApiClient` per includere
  la cronologia delle puntate.
- Aggiornato il monitor delle aste (`AuctionMonitor.cs`) per
  sincronizzare i dati della cronologia con il backend.
- Aggiunta la proprietà `BidHistoryEntries` in
  `AuctionViewModel` per il binding della griglia.
- Modificata la UI (`AuctionMonitorControl.xaml`) per
  includere la nuova scheda e personalizzare gli stili.
- Aggiornata la logica di aggiornamento UI in
  `MainWindow.xaml.cs` per gestire i dati della cronologia.
- Documentata la funzionalità in `FEATURE_BID_HISTORY_TAB.md`.
- Aggiunto uno screenshot (`Screenshot 2025-11-25 113552.png`).

Questa funzionalità migliora la trasparenza e fornisce agli
utenti informazioni dettagliate sulle attività recenti,
aiutandoli a prendere decisioni strategiche durante le aste.
2025-11-25 14:35:09 +01:00
Alby96 6795282993 Migliorato auto-login e gestione cookie WebView2
- Introdotto il pre-caricamento di WebView2 per ridurre i tempi di attesa.
- Implementato il pattern TaskCompletionSource per attendere l'inizializzazione di WebView2 (timeout 60s).
- Centralizzata la logica di verifica e importazione automatica dei cookie.
- Mostrate istruzioni di login solo se necessario, migliorando l'UX.
- Risolti problemi di timeout e threading durante l'inizializzazione di WebView2.
- Puliti e ottimizzati i log per maggiore chiarezza.
- Rimossa la gestione manuale dei cookie, ora automatizzata.
2025-11-25 11:33:50 +01:00
Alby96 62d5cebf9c Refactoring gestione sessione e persistenza impostazioni
Introdotto `SessionService` per centralizzare la gestione della
sessione utente, migliorando la separazione delle responsabilità
e la testabilità. Risolto il problema del caricamento del cookie
di autenticazione all'avvio e garantita la persistenza delle
checkbox di esportazione (`IncludeMetadata`, `RemoveAfterExport`,
`OverwriteExisting`).

Ottimizzata la gestione della barra degli indirizzi del browser
con aggiornamenti locali immediati. Applicato il pattern "Load ?
Modify ? Save" per il salvataggio delle impostazioni, migliorando
la simmetria e la leggibilità del codice. Logging centralizzato
e semplificato per eventi rilevanti.

Aggiornata la documentazione per riflettere i cambiamenti e
verificati i test per garantire il corretto funzionamento.
2025-11-24 12:00:13 +01:00
Alby96 ee67bedc31 Aggiunta calcolo valore prodotto e miglioramenti UI
Implementato il calcolo del valore reale dei prodotti in asta,
includendo il prezzo "Compra Subito", spese di spedizione e
risparmio stimato. Aggiunta una nuova sezione "Info Prodotto"
nella UI per visualizzare i dettagli estratti e i calcoli.

- **AuctionMonitorControl.xaml**: Aggiunta sezione fissa per
  mostrare informazioni prodotto e calcolo valore.
- **AuctionMonitorControl.xaml.cs**: Gestiti eventi per il
  caricamento e aggiornamento delle informazioni prodotto.
- **MainWindow**: Integrati handler per il calcolo e refresh
  delle informazioni prodotto.
- **AuctionInfo.cs**: Aggiunte proprietà per gestire prezzo
  "Compra Subito", spese di spedizione e limiti di vincita.
- **ProductValueCalculator.cs**: Nuova utility per calcolare
  il valore del prodotto e parsare informazioni dall'HTML.
- **AuctionViewModel.cs**: Binding per visualizzare risparmio,
  costo totale e convenienza nella UI.
- **Documentazione**: Aggiornata con dettagli sull'algoritmo
  di calcolo e layout UI.

Fix:
- Risolto problema di encoding UTF-8 per emoji nella UI.
- Migliorato parsing HTML per prezzi e limiti di vincita.

TODO:
- Testare parsing su più aste e gestire edge cases.
- Implementare caricamento automatico delle informazioni.
2025-11-21 16:55:21 +01:00
Alby96 f124f2e4e8 Riorganizzazione pulsanti e miglioramenti usabilità
- Riorganizzati i pulsanti azione asta in layout 2x2:
  * Aggiunti pulsanti per Browser Interno, Browser Esterno,
    Copia URL ed Esporta (funzionalità in sviluppo).
  * Migliorati stile, tooltip e colori per maggiore chiarezza.
- Aggiunti nuovi RoutedEvent e gestori per le azioni.
- Migliorata gestione errori per "Copia URL":
  * Controllo asta selezionata e retry per clipboard occupato.
- Rimosse emoji non visualizzate per compatibilità universale.
- Arricchiti i log con messaggi dettagliati per ogni azione.
- Creata documentazione dettagliata delle modifiche e test.
- Migliorata compatibilità e robustezza generale.
2025-11-21 10:30:49 +01:00
Alby96 570c2e53d6 Aggiunti limiti configurabili per i log
- Introdotta una nuova sezione "Limiti Log" nell'interfaccia utente per configurare:
  - Numero massimo di righe di log per asta (default: 500).
  - Numero massimo di righe di log globale (default: 1000).
- Aggiunte proprietà in `SettingsManager` per salvare/caricare i limiti.
- Applicati i limiti al log globale e ai log delle aste:
  - Log globale: rimozione automatica dei paragrafi più vecchi.
  - Log per asta: ottimizzato `AddLog` con `RemoveRange` per migliorare le performance.
- Documentazione dettagliata in `FEATURE_CONFIGURABLE_LOG_LIMITS.md` e `FEATURE_LOG_MAX_LINES.md`.
- Migliorata la gestione della memoria, riducendo il rischio di rallentamenti o crash.
- Test e checklist definiti per verificare il corretto funzionamento.
2025-11-21 09:41:08 +01:00
Alby96 4bfcf147b4 Miglioramenti UI e gestione puntate server
- Implementato focus automatico sulla riga successiva dopo la
  cancellazione di un'asta, con scrolling e reset focus.
- Utilizzo dei dati ufficiali del server per il conteggio
  delle puntate residue e usate su asta, con fallback manuale.
- Corretto il parsing dei campi della risposta server
  (campo 2: puntate residue, campo 5: puntate usate).
- Risolto il mancato aggiornamento immediato della UI
  (colonna "Clicks" e banner "Puntate residue").
- Aggiunto logging dettagliato per il parsing della risposta
  server e il debugging di eventuali problemi.
- Documentate le modifiche in file dedicati con scenari di
  test e istruzioni per il troubleshooting.
2025-11-20 23:01:53 +01:00
Alby96 c37b5b9f1e Aggiorna soluzione per Visual Studio 18 e rimuove Template
La soluzione `AutoBidder.sln` è stata aggiornata per supportare
Visual Studio 18 (18.0.11217.181), sostituendo la versione
precedente (17.14.36511.14).

Il progetto "Template" (`Template.wapproj`) è stato rimosso
insieme a tutte le configurazioni di build e deploy associate
per le piattaforme Any CPU, ARM, ARM64, x64 e x86 in modalità
Debug e Release.

Le configurazioni di build per il progetto "AutoBidder" sono
state mantenute, ma alcune configurazioni di Release sono state
modificate per utilizzare `Any CPU` invece di configurazioni
specifiche per piattaforma.
2025-11-20 14:48:21 +01:00
Alberto Balbo 29a567bb1d Miglioramenti UX e gestione impostazioni predefinite
* Rimosso il pulsante "Vai" e reso il campo URL non editabile.
* Introdotta persistenza delle impostazioni predefinite (es. anticipo).
* Aggiunto metodo `LoadDefaultSettings()` per caricare i defaults.
* Logging dettagliato per salvataggio e applicazione impostazioni.
* Ottimizzata gestione aste con valori predefiniti da configurazione.
* Fix per evitare puntate inutili quando l'utente è già vincitore.
* Logging migliorato per strategia di puntata e decisioni di skip.
* Aggiornata documentazione con dettagli sui fix implementati.
* Aggiornato `CHANGELOG.md` con le nuove funzionalità e correzioni.
2025-11-20 14:11:37 +01:00
Alberto Balbo f017ec0364 Aggiornamento alla versione 4.0.0
* Aggiunto `BooleanToOpacityConverter` per gestire opacità dinamica.
* Introdotto nuovo sistema di timing con `BidBeforeDeadlineMs`.
* Aggiunta opzione `CheckAuctionOpenBeforeBid` per maggiore sicurezza.
* Implementato polling adattivo (10ms-1000ms) e cooldown di 800ms.
* Migliorata gestione pulsanti globali con supporto `AUTO-START`/`AUTO-STOP`.
* Fix per il tasto `Canc` e focus automatico sul `DataGrid`.
* Fix per avvio singola asta senza necessità di "Avvia Tutti".
* Aggiornati formati CSV/JSON/XML con nuovi campi.
* Migliorata gestione cookie con endpoint unico `buy_bids.php`.
* Miglioramenti UI/UX: tooltip, formattazione prezzi, feedback visivo.
* Aggiornata documentazione e changelog per la versione 4.0.0.
2025-11-19 18:43:40 +01:00
Alberto Balbo 6036896f7d Refactoring e nuove funzionalità per AutoBidder v4.0
* Aggiornamento alla versione 4.0.0
* Refactoring architetturale: introdotte partial classes e UserControls modulari per migliorare manutenibilità e leggibilità.
* Aggiunti nuovi UserControls: `AuctionMonitorControl`, `BrowserControl`, `SettingsControl`, `StatisticsControl`.
* Introdotto supporto per WebView2 per il browser integrato.
* Migliorata gestione delle aste: aggiunta/rimozione tramite URL o ID, configurazione predefinita.
* Nuove funzionalità di esportazione: supporto CSV, JSON, XML con opzioni configurabili.
* Logging avanzato: codifica colore per severità e auto-scroll.
* Tema scuro moderno e miglioramenti UI/UX: sidebar di navigazione, griglie virtualizzate, icone emoji.
* Persistenza dati: salvataggio automatico di aste e impostazioni in file JSON.
* Documentazione aggiornata: `README.md`, `CHANGELOG.md` e nuovi file di supporto.
* Miglioramenti alla sicurezza: cookie di sessione salvati in modo sicuro con DPAPI.
* Preparazione per future estensioni: placeholder per funzionalità avanzate e struttura modulare.
2025-11-17 16:01:22 +01:00
Alberto Balbo 8717a3b6ef Aggiornamento alla versione 4.0.0
- Aggiunta dipendenza per WebView2 per integrare un browser.
- Introdotto layout a schede (TabControl) per organizzare le funzionalità.
- Aggiunto browser WebView2 per navigazione e aggiunta aste.
- Implementata gestione delle impostazioni di esportazione (CSV, JSON, XML).
- Aggiunta funzionalità di caricamento e analisi delle aste chiuse.
- Introdotta gestione dei cookie di sessione tramite la scheda "Impostazioni".
- Creato controllo personalizzato `SimpleToolbar` per layout modulare.
- Migliorata gestione dello stato utente e fallback per dati mancanti.
- Rimossi stili e animazioni obsolete per semplificare il codice.
- Salvate le impostazioni utente in un file JSON locale.
- Correzioni di bug e miglioramenti di leggibilità del codice.
2025-11-04 23:05:49 +01:00
Alberto Balbo 967005b96a Supporto per aste chiuse e miglioramenti UI
- Aggiornamento alla versione Microsoft.EntityFrameworkCore.Sqlite 8.0.0.
- Aggiornamento alla versione Microsoft.Windows.SDK.BuildTools 10.0.26100.6584.
- Migliorata l'interfaccia per l'inserimento di più URL/ID di aste.
- Aggiunti pulsanti per "Aste Chiuse" e "Esporta" in MainWindow.
- Creata finestra "Aste Chiuse" per visualizzare e gestire aste chiuse.
- Implementato scraper per estrarre dati da aste chiuse.
- Aggiunto supporto per esportazione dati in CSV, JSON e XML.
- Introdotto contesto Entity Framework per statistiche delle aste.
- Aggiunto servizio per calcolo e gestione delle statistiche.
- Gestite preferenze di esportazione con salvataggio in file JSON.
2025-11-03 14:24:19 +01:00
Alberto Balbo 59d7e0c41f Aggiornamento alla versione 3.0.0
- Aggiornato il titolo e l'introduzione per riflettere le funzionalità.
- Aggiunti badge per la versione (3.0) e .NET (8.0).
- Riorganizzato il sommario e rinominato in "Indice".
- Espansa la sezione "Caratteristiche principali" con nuovi dettagli.
- Riformattati i requisiti di sistema per maggiore chiarezza.
- Riscritta la guida "Installazione e avvio" con istruzioni dettagliate.
- Aggiunta la sezione "Configurazione della sessione (cookie)".
- Ampliata la gestione delle aste con dettagli su operazioni e griglia.
- Inserite strategie consigliate per diversi scenari d'uso.
- Arricchita la sezione tecnica con dettagli su polling e puntate HTTP.
- Aggiunta descrizione su persistenza e diagnostica (es. esportazione CSV).
- Espansa la sezione FAQ con nuovi scenari di risoluzione problemi.
- Introdotte note su sicurezza e responsabilità nell'uso dell'app.
- Semplificata la sezione "Supporto" rimuovendo riferimenti privati.
- Aggiunte note tecniche per sviluppatori sui file principali del progetto.
- Rimossa la sezione "Changelog sintetico" e integrati i dettagli altrove.
- Riformattata la sezione "Licenza" per maggiore chiarezza.
2025-10-30 13:02:13 +01:00
Alberto Balbo daf9ea31fc Miglioramenti logica e gestione attacco finale
- Aggiunta proprietà `FinalAttackThresholdSec` (0.8s) in `AuctionInfo.cs`.
- Implementata strategia di "quick re-poll" in `AuctionMonitor.cs` per confermare stato critico prima dell'attacco finale.
- Migliorata gestione delle eccezioni in `BidooApiClient.cs` con log dettagliati e tentativi alternativi.
- Registrazione del numero di offerte rimanenti dopo successo in `BidooApiClient.cs`.
- Ottimizzati messaggi di log per maggiore chiarezza e trasparenza.
- Rimossa logica obsoleta e aggiunti ritardi minimi tra tentativi di polling rapido.
2025-10-29 17:12:46 +01:00
Alberto Balbo cb30e1fb08 Ottimizzazioni e introduzione protocollo final attack
- Rimosso binding di `MaxClicks` in `MainWindow.xaml` e aggiunto evento `TextChanged`.
- Migliorata leggibilità del codice in `MainWindow.xaml.cs` con rientri e ritorni a capo.
- Evitata duplicazione dei log per aste aggiunte in `MainWindow.xaml.cs`.
- Migliorata gestione della validità del cookie con fallback su scraping HTML.
- Aggiunta proprietà `IsAttackInProgress` in `AuctionInfo.cs` per gestire lo stato di attacco finale.
- Introdotto protocollo di "final attack" in `AuctionMonitor.cs` per puntate critiche sotto 0,5s.
- Migliorata gestione dei log e comportamento delle puntate normali in `AuctionMonitor.cs`.
- Aggiunto metodo `PlaceBidFinalAsync` in `BidooApiClient.cs` per puntate ottimizzate.
- Ridotti log ridondanti e migliorata gestione degli errori.
2025-10-28 23:27:53 +01:00
Alberto Balbo 717dc44b3b Aggiornamento alla versione 3.0.0
- Rimosse funzionalità legacy legate a WebView2.
- Introdotto uno stile globale per i pulsanti.
- Semplificata l'interfaccia con gestione tramite griglia unica.
- Aggiunti comandi per avviare, mettere in pausa e fermare aste.
- Introdotta gestione manuale dei cookie tramite dialog.
- Aggiunti dialog per configurare sessione e aggiungere aste.
- Migliorata la persistenza con salvataggio sicuro (DPAPI).
- Rifattorizzate statistiche per utilizzare `BidHistory` e `BidderStats`.
- Ottimizzato il polling per ridurre il carico di sistema.
- Aggiornata esportazione CSV con dati più dettagliati.
- Introdotti nuovi modelli dati per utente e banner aste.
- Rimossi file di test manuale e codice obsoleto.
- Aggiornata documentazione per riflettere le modifiche.
- Aggiunta nuova icona dell'applicazione.
- Migliorata la sicurezza eliminando il salvataggio in chiaro dei cookie.
2025-10-28 12:45:08 +01:00
385 changed files with 262394 additions and 4040 deletions
+18
View File
@@ -414,3 +414,21 @@ FodyWeavers.xsd
# Built Visual Studio Code Extensions
*.vsix
# ---> AutoBidder / Mimante
# Configurazione di rilascio: contiene un token di Gitea
gitea.json
# Riepilogo della rigiocata sui dossier (target Backtest)
*backtest-report.txt
# Pacchetti prodotti da build/Release.proj
bin/installer/
# Rider / JetBrains
.idea/
# Windows
Thumbs.db
Desktop.ini
+23
View File
@@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug AutoBidder (WPF)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/bin/Debug/net10.0-windows/AutoBidder.exe",
"args": [],
"cwd": "${workspaceFolder}",
"console": "internalConsole",
"stopAtEntry": false,
"enableStepFiltering": true
},
{
"name": "Attach to AutoBidder (processo già avviato)",
"type": "coreclr",
"request": "attach",
"processName": "AutoBidder.exe"
}
]
}
+180
View File
@@ -0,0 +1,180 @@
{
// Tutte le attività passano da build/Release.proj: la catena è un solo file
// MSBuild versionato col codice, e qui restano soltanto i nomi e le domande.
// MSBuild non può chiedere niente a nessuno — i prompt stanno in "inputs".
"version": "2.0.0",
"tasks": [
{
"label": "build",
"detail": "Compilazione di debug, per F5 e per il controllo rapido degli errori.",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/AutoBidder.csproj",
"-c",
"Debug",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
"label": "verifica",
"detail": "Compila e lancia i test. Da eseguire dopo ogni modifica.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Verifica",
"-nologo",
"-v:m"
],
"group": {
"kind": "test",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
"label": "backtest",
"detail": "Rigioca i dossier delle aste concluse: quante puntate costerebbe ciascun anticipo, e quante ne fermano i controlli di budget.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Backtest",
"-nologo",
"-v:m"
],
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "backtest (giro veloce, 400 aste)",
"detail": "Come sopra ma su un campione: utile mentre si tarano le strategie.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Backtest",
"-p:MaxDossier=400",
"-nologo",
"-v:m"
],
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "valuta apprendimento",
"detail": "Addestra il modello sui dossier più vecchi e lo giudica sui più recenti: separazione, calibrazione, e cosa avrebbe fatto il cancello sulle tue puntate vere.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Apprendimento",
"-nologo",
"-v:m"
],
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "crea installatore",
"detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/AutoBidder-<versione>-setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Pacchetto",
"-p:Versione=${input:versione}",
"-nologo",
"-v:m"
],
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": "$msCompile"
},
{
"label": "crea installatore (senza rieseguire i test)",
"detail": "Solo pubblicazione e Inno Setup. Da usare quando i test sono appena passati.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Pacchetto",
"-p:Versione=${input:versione}",
"-p:SaltaVerifica=true",
"-nologo",
"-v:m"
],
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": "$msCompile"
},
{
"label": "rilascia su Gitea",
"detail": "Verifica, pubblica, installatore, tag e release su Gitea con i file allegati. La versione viene dal tag su HEAD. Richiede build/gitea.json.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Rilascia",
"-p:Versione=${input:versione}",
"-nologo",
"-v:m"
],
// Le note passano dall'ambiente, non da -p:. MSBuild spezza il valore di
// una proprietà sulle virgole e una nota in italiano ne ha quasi sempre
// una: si otterrebbe MSB1006 «proprietà non valida». Vedi Release.proj.
"options": {
"env": {
"AUTOBIDDER_NOTE": "${input:note}"
}
},
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": "$msCompile"
}
],
"inputs": [
{
"id": "versione",
"type": "promptString",
"description": "Versione — lascia vuoto se hai già taggato (git tag v4.14.0), o per la minor successiva",
"default": ""
},
{
"id": "note",
"type": "promptString",
"description": "Note di rilascio (vuoto = solo il numero di versione)",
"default": ""
}
]
}
+6 -3
View File
@@ -1,12 +1,15 @@
<Application x:Class="Mimante.App"
<Application x:Class="AutoBidder.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Mimante"
xmlns:local="clr-namespace:AutoBidder"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Converters/Converters.xaml" />
<!-- [0] SLOT TEMA: sostituito a runtime da ThemeManager (Dark/Light) -->
<ResourceDictionary Source="Themes/Tokens.Dark.xaml"/>
<!-- [1] Stili controlli (usano DynamicResource sui token) -->
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
+93 -4
View File
@@ -1,14 +1,103 @@
using System.Configuration;
using System.Data;
using System;
using System.Windows;
using AutoBidder.Utilities;
namespace Mimante
namespace AutoBidder
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Le descrizioni delle impostazioni vivono nei suggerimenti invece che stampate
// sotto ogni campo. Quello predefinito di Windows sparisce dopo cinque secondi:
// per un paragrafo di tre righe significa doverlo rincorrere.
System.Windows.Controls.ToolTipService.ShowDurationProperty.OverrideMetadata(
typeof(DependencyObject), new FrameworkPropertyMetadata(60000));
// I percorsi vanno decisi per primi: da qui in poi ogni archivio li usa.
// Le impostazioni stanno nella radice fissa, quindi si possono leggere anche
// prima di sapere dove finiranno gli altri dati.
var settings = SettingsManager.Load();
AppPaths.Configure(settings.DatabaseFolder);
AppPaths.EnsureFolders();
// I due database si aprono per primi: da qui in poi ogni archivio e ogni
// registro ci scrive. Quello d'esercizio per primo, perché il registro
// applicativo sta lì dentro.
Data.SqliteDatabase.OnLog += m => TextLogService.App("INFO", m);
try
{
var op = Data.OperationalDatabase.Instance;
TextLogService.SessionStarted(AppInfo.Version);
TextLogService.App("INFO", $"[DATABASE] Esercizio: {op.Path} ({op.SizeBytes / 1024:N0} KB)");
var db = Data.AuctionDatabase.Instance;
TextLogService.App("INFO", $"[DATABASE] Osservazioni: {db.Path} (SQLite {Data.SqliteDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)");
db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.SqliteDatabase.Now(), AppInfo.Version);
}
catch (Exception ex)
{
TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}");
}
TextLogService.PurgeOldLogs();
// Le righe di registro delle aste confluiscono nel database.
Data.AuctionRecorder.CaptureAuctionLogs();
// Lo storico e' cresciuto per mesi con versioni diverse dell'applicazione:
// alcuni record hanno campi che allora non esistevano. Quelli deducibili si
// ricalcolano qui, una volta, prima che qualcuno li legga. Quelli NON
// deducibili non si toccano: se ne occupa la pulizia, che chiede all'utente.
try
{
var repair = CompletedAuctionsStore.RepairOnStartup();
if (repair.AnythingDone)
{
TextLogService.App("INFO", $"[STORICO] Riparati {repair.Total} campi mancanti " +
$"(chiavi prodotto {repair.ProductKeys}, esiti {repair.Outcomes}, " +
$"nomi {repair.Names})");
}
}
catch (Exception ex)
{
TextLogService.App("WARN", $"[STORICO] Riparazione non riuscita: {ex.Message}");
}
// L'apprendimento è sempre acceso: carica ciò che sa, ricostruisce il profilo
// dallo storico se manca, e in sottofondo legge i dossier non ancora appresi.
Ml.LearningService.OnLog += m => TextLogService.App("INFO", m);
Ml.LearningService.Start(settings);
// Applica il tema salvato (chiaro/scuro) prima di mostrare la finestra
ThemeManager.ApplyFromSettings();
}
protected override void OnExit(ExitEventArgs e)
{
// Quello che è ancora in coda va sul disco adesso: un registro che perde
// proprio le ultime righe è inutile esattamente quando serve di più.
TextLogService.SessionEnded();
try
{
var db = Data.AuctionDatabase.Instance;
db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.SqliteDatabase.Now());
Data.AuctionDatabase.CloseInstance();
Data.OperationalDatabase.CloseInstance();
}
catch { /* all'uscita non c'è più nessuno a cui dirlo */ }
// L'icona di notifica sopravvive al processo se non la si rimuove:
// resterebbe nell'area di notifica come icona fantasma.
WindowsNotifier.Dispose();
base.OnExit(e);
}
}
}
-2
View File
@@ -1,2 +0,0 @@
[Binary ICO placeholder removed in this environment]
+39 -6
View File
@@ -1,23 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<!-- Solo per NotifyIcon: le notifiche moderne richiederebbero un'applicazione
registrata, incompatibile con l'avvio da singolo eseguibile. -->
<UseWindowsForms>true</UseWindowsForms>
<AssemblyName>AutoBidder</AssemblyName>
<RootNamespace>AutoBidder</RootNamespace>
<ApplicationIcon>Icon\favicon.ico</ApplicationIcon>
<!-- App desktop leggera per Bidoo. Nessun software aggiuntivo:
WebView2 runtime è preinstallato su Windows 11. -->
<!-- Versione delle sole compilazioni di sviluppo. NON va alzata per rilasciare:
nei pacchetti pubblicati questi quattro numeri vengono dal tag git, passati
da build/Release.proj a dotnet publish. Vedi Utilities/AppInfo. -->
<Version>4.13.0</Version>
<AssemblyVersion>4.13.0.0</AssemblyVersion>
<FileVersion>4.13.0.0</FileVersion>
<InformationalVersion>4.13.0</InformationalVersion>
<!-- Runtime unico supportato per la pubblicazione self-contained -->
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<SatelliteResourceLanguages>it;en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3530-prerelease" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584" />
<!-- Il progetto di test vive in una sottocartella: senza questa esclusione i
progetti SDK-style, che raccolgono **/*.cs, lo compilerebbero qui dentro. -->
<Compile Remove="Tests\**" />
<None Remove="Tests\**" />
</ItemGroup>
<ItemGroup>
<!-- Ensure the application icon is included as a WPF Resource so Icon="Assets/app.ico" resolves -->
<Resource Include="Assets\app.ico" />
<!-- UseWindowsForms aggiunge questi using in tutti i file, e ogni nome in comune
con WPF (UserControl, TextBox, Application, Color...) diventa ambiguo.
Serve solo a WindowsNotifier, che li importa per conto proprio. -->
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3351.48" />
</ItemGroup>
<ItemGroup>
<!-- Incorporata come Resource (non Content) così l'icona della finestra
funziona anche in modalità single-file self-contained. -->
<Resource Include="Icon\favicon.ico" />
</ItemGroup>
</Project>
+19 -42
View File
@@ -1,78 +1,55 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36511.14
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoBidder", "AutoBidder.csproj", "{9BBAEF93-DF66-432C-9349-459E272D6538}"
EndProject
Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "Template", "..\Template\Template.wapproj", "{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoBidder.Tests", "Tests\AutoBidder.Tests.csproj", "{CAC096C0-E399-4268-B2FF-E205947CB733}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|ARM = Debug|ARM
Debug|ARM64 = Debug|ARM64
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|ARM = Release|ARM
Release|ARM64 = Release|ARM64
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|ARM.ActiveCfg = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|ARM.Build.0 = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|ARM64.Build.0 = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|x64.ActiveCfg = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|x64.Build.0 = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|x86.ActiveCfg = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Debug|x86.Build.0 = Debug|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|Any CPU.Build.0 = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|ARM.ActiveCfg = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|ARM.Build.0 = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|ARM64.ActiveCfg = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|ARM64.Build.0 = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|x64.ActiveCfg = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|x64.Build.0 = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|x86.ActiveCfg = Release|Any CPU
{9BBAEF93-DF66-432C-9349-459E272D6538}.Release|x86.Build.0 = Release|Any CPU
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|ARM.ActiveCfg = Debug|ARM
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|ARM.Build.0 = Debug|ARM
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|ARM.Deploy.0 = Debug|ARM
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|ARM64.ActiveCfg = Debug|ARM64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|ARM64.Build.0 = Debug|ARM64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|ARM64.Deploy.0 = Debug|ARM64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|x64.ActiveCfg = Debug|x64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|x64.Build.0 = Debug|x64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|x64.Deploy.0 = Debug|x64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|x86.ActiveCfg = Debug|x86
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|x86.Build.0 = Debug|x86
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Debug|x86.Deploy.0 = Debug|x86
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|ARM.ActiveCfg = Release|ARM
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|ARM.Build.0 = Release|ARM
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|ARM.Deploy.0 = Release|ARM
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|ARM64.ActiveCfg = Release|ARM64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|ARM64.Build.0 = Release|ARM64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|ARM64.Deploy.0 = Release|ARM64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|x64.ActiveCfg = Release|x64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|x64.Build.0 = Release|x64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|x64.Deploy.0 = Release|x64
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|x86.ActiveCfg = Release|x86
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|x86.Build.0 = Release|x86
{1D9DB6F9-BD2B-4B14-9F2E-104060FAAD1E}.Release|x86.Deploy.0 = Release|x86
{CAC096C0-E399-4268-B2FF-E205947CB733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Debug|x64.ActiveCfg = Debug|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Debug|x64.Build.0 = Debug|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Debug|x86.ActiveCfg = Debug|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Debug|x86.Build.0 = Debug|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Release|Any CPU.Build.0 = Release|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Release|x64.ActiveCfg = Release|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Release|x64.Build.0 = Release|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Release|x86.ActiveCfg = Release|Any CPU
{CAC096C0-E399-4268-B2FF-E205947CB733}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CAC096C0-E399-4268-B2FF-E205947CB733} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1C55CA56-D270-4D9A-91DA-410BF131E905}
EndGlobalSection
-93
View File
@@ -1,93 +0,0 @@
<Window x:Class="AutoBidder.BrowserWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="Browser - Bidoo"
Height="800" Width="1200"
Background="#101219" Foreground="#E6EDF3">
<Window.Resources>
<Style x:Key="AddressBarStyle" TargetType="TextBox">
<Setter Property="Background" Value="#0B1220" />
<Setter Property="Foreground" Value="#E6EDF3" />
<Setter Property="BorderBrush" Value="#263143" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,6" />
<Setter Property="FontSize" Value="13" />
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Barra navigazione -->
<Grid Grid.Row="0" Margin="12,12,12,6" Background="#0B1015">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button x:Name="BackButton" Grid.Column="0" Content="?" Click="BackButton_Click"
Background="#374151" Foreground="White" Padding="12,8" Margin="8,8,6,8"
BorderThickness="0" FontWeight="Bold" FontSize="14" MinWidth="40" Height="38" IsEnabled="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Button.Template>
</Button>
<Button x:Name="RefreshButton" Grid.Column="1" Content="?" Click="RefreshButton_Click"
Background="#0EA5E9" Foreground="White" Padding="12,8" Margin="0,8,8,8"
BorderThickness="0" FontWeight="Bold" FontSize="16" MinWidth="40" Height="38">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Button.Template>
</Button>
<TextBox x:Name="AddressBar" Grid.Column="2" Style="{StaticResource AddressBarStyle}"
Text="https://it.bidoo.com" KeyDown="AddressBar_KeyDown" Margin="0,8,8,8" />
<Button x:Name="NavigateButton" Grid.Column="3" Content="Vai" Click="NavigateButton_Click"
Background="#16A34A" Foreground="White" Padding="20,8" Margin="0,8,8,8"
BorderThickness="0" FontWeight="Bold" FontSize="14" Height="38">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Button.Template>
</Button>
<Button x:Name="AddCurrentPageButton" Grid.Column="4" Content="? Aggiungi Asta" Click="AddCurrentPageButton_Click"
Background="#8B5CF6" Foreground="White" Padding="16,8" Margin="0,8,8,8"
BorderThickness="0" FontWeight="SemiBold" FontSize="13" Height="38">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
<!-- WebView -->
<Border Grid.Row="1" Margin="12,0,12,12" CornerRadius="8" Background="#0B1015">
<wv2:WebView2 x:Name="webView" Source="https://it.bidoo.com" Margin="2" />
</Border>
</Grid>
</Window>
-204
View File
@@ -1,204 +0,0 @@
using System;
using System.Windows;
using System.Windows.Input;
using Microsoft.Web.WebView2.Core;
namespace AutoBidder
{
/// <summary>
/// Finestra browser separata per navigazione Bidoo
/// </summary>
public partial class BrowserWindow : Window
{
public event Action<string>? OnAddAuction;
public BrowserWindow()
{
InitializeComponent();
Loaded += BrowserWindow_Loaded;
}
private async void BrowserWindow_Loaded(object sender, RoutedEventArgs e)
{
try
{
if (webView.CoreWebView2 == null)
{
await webView.EnsureCoreWebView2Async();
}
webView.NavigationCompleted += WebView_NavigationCompleted;
webView.NavigationStarting += WebView_NavigationStarting;
// Context menu per aggiungere asta
if (webView.CoreWebView2 != null)
{
webView.CoreWebView2.ContextMenuRequested += CoreWebView2_ContextMenuRequested;
webView.CoreWebView2.Navigate("https://it.bidoo.com");
}
}
catch (Exception ex)
{
MessageBox.Show($"Errore inizializzazione: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void CoreWebView2_ContextMenuRequested(object? sender, CoreWebView2ContextMenuRequestedEventArgs e)
{
try
{
var currentUrl = webView.CoreWebView2?.Source ?? "";
if (IsValidAuctionUrl(currentUrl) && webView.CoreWebView2 != null)
{
// Aggiungi voce menu contestuale
var menuItem = webView.CoreWebView2.Environment.CreateContextMenuItem(
"Aggiungi asta al monitoraggio",
null,
CoreWebView2ContextMenuItemKind.Command);
menuItem.CustomItemSelected += (s, args) =>
{
OnAddAuction?.Invoke(currentUrl);
};
e.MenuItems.Insert(0, menuItem);
}
}
catch { }
}
private void WebView_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
if (!string.IsNullOrEmpty(e.Uri) && !IsBidooUrl(e.Uri))
{
e.Cancel = true;
MessageBox.Show("Solo domini Bidoo consentiti!", "Navigazione Bloccata", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private void WebView_NavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
try
{
Dispatcher.BeginInvoke(() =>
{
BackButton.IsEnabled = webView.CoreWebView2?.CanGoBack ?? false;
var url = webView.CoreWebView2?.Source ?? "";
if (!string.IsNullOrEmpty(url))
{
AddressBar.Text = url;
}
});
}
catch { }
}
private bool IsBidooUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
try
{
var uri = new Uri(url);
var host = uri.Host.ToLowerInvariant();
return host.Contains("bidoo.com") || host.Contains("bidoo.it") ||
host.Contains("bidoo.fr") || host.Contains("bidoo.es") ||
host.Contains("bidoo.de");
}
catch
{
return false;
}
}
private bool IsValidAuctionUrl(string url)
{
if (!IsBidooUrl(url)) return false;
try
{
var uri = new Uri(url);
return uri.AbsolutePath.Contains("/asta/") || uri.Query.Contains("?a=");
}
catch
{
return false;
}
}
private void BackButton_Click(object sender, RoutedEventArgs e)
{
webView.CoreWebView2?.GoBack();
}
private void RefreshButton_Click(object sender, RoutedEventArgs e)
{
webView.CoreWebView2?.Reload();
}
private void NavigateButton_Click(object sender, RoutedEventArgs e)
{
NavigateToAddress();
}
private void AddressBar_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
NavigateToAddress();
}
}
private void NavigateToAddress()
{
try
{
var url = AddressBar.Text.Trim();
if (string.IsNullOrEmpty(url)) return;
if (!url.StartsWith("http"))
{
url = "https://" + url;
}
if (!IsBidooUrl(url))
{
MessageBox.Show("Solo URL Bidoo consentiti!", "URL Non Valido", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
webView.CoreWebView2?.Navigate(url);
}
catch (Exception ex)
{
MessageBox.Show($"Errore navigazione: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void AddCurrentPageButton_Click(object sender, RoutedEventArgs e)
{
try
{
var currentUrl = webView.CoreWebView2?.Source ?? "";
if (string.IsNullOrEmpty(currentUrl) || !IsValidAuctionUrl(currentUrl))
{
MessageBox.Show("La pagina corrente non un'asta valida!", "URL Non Valido", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
OnAddAuction?.Invoke(currentUrl);
MessageBox.Show("Asta aggiunta al monitoraggio!", "Successo", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
+766
View File
@@ -0,0 +1,766 @@
<UserControl x:Class="AutoBidder.Controls.AuctionMonitorControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:util="clr-namespace:AutoBidder.Utilities"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<util:BooleanToOpacityConverter x:Key="BoolToOpacity"/>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<!-- Icone: Segoe MDL2 Assets e' un font di sistema, monocromatico e senza
file da distribuire (che sotto PublishSingleFile non verrebbero estratti). -->
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="PillText" TargetType="TextBlock">
<Setter Property="FontSize" Value="{StaticResource Font.Size.Sm}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="5"/>
<RowDefinition Height="270" MinHeight="150"/>
</Grid.RowDefinitions>
<!-- ═══════════════ BARRA STRUMENTI ═══════════════ -->
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Comandi globali -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="StartButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Success}"
ToolTip="Avvia tutte: seguono e puntano"
Click="StartButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE768;"/>
</Button>
<Button x:Name="PauseAllButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Warning}"
ToolTip="Osserva tutte: seguono senza mai puntare"
Click="PauseAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE769;"/>
</Button>
<Button x:Name="StopButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ferma tutte: nessuna richiesta"
Click="StopButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/>
</Button>
<ToggleButton x:Name="LearningToggle" Style="{StaticResource IconToggle}"
ToolTip="Apprendimento. ACCESO: il motore decide con il valore atteso appreso, il regime di concorrenza, il riconoscimento del duello e l'anticipo adattivo dalla rete. SPENTO: si punta solo entro i tuoi limiti — prezzo minimo e massimo, puntate, budget, fascia oraria — con l'anticipo fisso delle impostazioni, come un cecchino senza cervello. In entrambi i casi le aste vengono registrate e studiate."
Checked="LearningToggle_Changed" Unchecked="LearningToggle_Changed">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE99A;"/>
</ToggleButton>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="8,4"/>
</StackPanel>
<!-- Contatori -->
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Pill}" ToolTip="Aste in elenco">
<TextBlock x:Name="PillTotal" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.TextMuted}" Text="0 aste"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Attive: seguono e puntano">
<TextBlock x:Name="PillActive" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Success}" Text="0 attive"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="In sola osservazione">
<TextBlock x:Name="PillWatch" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Warning}" Text="0 osserva"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Ferme">
<TextBlock x:Name="PillStopped" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.TextFaint}" Text="0 ferme"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Aste vinte">
<TextBlock x:Name="PillWon" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Gold}" Text="0 vinte"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Aste perse">
<TextBlock x:Name="PillLost" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Danger}" Text="0 perse"/>
</Border>
</StackPanel>
<!-- Stato conto e motore -->
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="10,0">
<!--
Solo numeri: l'etichetta sta nel suggerimento. Una barra di sigle
("punt.", "req") si legge peggio di un numero pulito, e abbreviare
una parola per farla stare non aiuta nessuno.
-->
<Border x:Name="RemainingBidsPill" Style="{StaticResource Pill}"
ToolTip="Numero di puntate sul conto">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="RemainingBidsText" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Text}" Text="—"/>
<TextBlock x:Name="MinBidsLimitIndicator" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Warning}" Margin="4,0,0,0"
Text="(20)" Visibility="Collapsed"
ToolTip="Limite minimo puntate attivo"/>
</StackPanel>
</Border>
<Border x:Name="ShopCreditPill" Style="{StaticResource Pill}"
ToolTip="Credito Bidoo Shop">
<TextBlock x:Name="ShopCreditText" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.TextMuted}" Text="— €"/>
</Border>
<!-- Nascosta quando non c'è nulla da confermare: come fa il sito. -->
<Border x:Name="ToConfirmPill" Style="{StaticResource Pill}"
Visibility="Collapsed"
ToolTip="Aste vinte in attesa di conferma su Bidoo">
<TextBlock x:Name="BannerAsteDaRiscattare" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.Gold}" Text="0"/>
</Border>
<Border x:Name="RequestsPill" Style="{StaticResource Pill}"
ToolTip="Numero di richieste inviate a Bidoo in questa sessione">
<TextBlock x:Name="RequestsText" Style="{StaticResource PillText}"
Foreground="{DynamicResource Brush.TextFaint}" Text="0"/>
</Border>
<!--
Stato del collegamento: pallino colorato + ritardo medio di rete.
Il colore dice a colpo d'occhio se il motore è in grado di puntare
puntuale; il numero e il suggerimento dicono perché.
-->
<Border x:Name="ClockPill" Style="{StaticResource Pill}">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="ClockDot" Style="{StaticResource PillText}" Text="&#x25CF;"
Foreground="{DynamicResource Brush.TextFaint}"/>
<TextBlock x:Name="ClockText" Style="{StaticResource PillText}" Margin="5,0,0,0"
Foreground="{DynamicResource Brush.TextFaint}" Text="—"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Gestione elenco -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="4,4,8,4"/>
<Button x:Name="AddUrlButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Aggiungi asta" Click="AddUrlButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE710;"/>
</Button>
<Button x:Name="RemoveUrlButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Rimuovi l'asta selezionata" Click="RemoveUrlButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;"/>
</Button>
<Button x:Name="RemoveFinishedButton" Style="{StaticResource IconButton}"
ToolTip="Rimuovi le aste concluse" Click="RemoveFinishedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE81C;"/>
</Button>
<Button x:Name="RemoveAllButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Rimuovi tutte le aste" Click="RemoveAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE711;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ═══════════════ ASTE + LOG ═══════════════ -->
<Grid Grid.Row="1" Margin="8,8,8,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="5*" MinWidth="420"/>
<ColumnDefinition Width="5"/>
<ColumnDefinition Width="2*" MinWidth="170"/>
</Grid.ColumnDefinitions>
<!-- Aste monitorate -->
<Border Grid.Column="0" Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Padding="12,8" BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1">
<Grid>
<TextBlock x:Name="MonitorateTitle" Text="Aste monitorate: 0"
FontWeight="SemiBold" FontSize="{StaticResource Font.Size.Base}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<TextBlock Text="Clicca un'intestazione per ordinare"
HorizontalAlignment="Right" VerticalAlignment="Center"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"/>
</Grid>
</Border>
<DataGrid Grid.Row="1" x:Name="MultiAuctionsGrid"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
CanUserSortColumns="True"
Background="Transparent"
SelectionChanged="MultiAuctionsGrid_SelectionChanged"
PreviewKeyDown="MultiAuctionsGrid_PreviewKeyDown">
<DataGrid.Columns>
<!-- Stato: e' la colonna che si legge per prima, quindi un badge colorato -->
<DataGridTemplateColumn Header="Stato" Width="Auto" MinWidth="76"
SortMemberPath="StatusKind">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border>
<Border.Style>
<Style TargetType="Border" BasedOn="{StaticResource StatusBadge}">
<Setter Property="Background" Value="{DynamicResource Brush.SurfaceAlt}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.TextFaint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StatusKind}" Value="won">
<Setter Property="Background" Value="{DynamicResource Brush.GoldSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.Gold}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StatusKind}" Value="lost">
<Setter Property="Background" Value="{DynamicResource Brush.DangerSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.Danger}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StatusKind}" Value="leading">
<Setter Property="Background" Value="{DynamicResource Brush.SuccessSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.Success}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StatusKind}" Value="active">
<Setter Property="Background" Value="{DynamicResource Brush.AccentSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.Accent}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StatusKind}" Value="watch">
<Setter Property="Background" Value="{DynamicResource Brush.WarningSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StatusKind}" Value="suspended">
<Setter Property="Background" Value="{DynamicResource Brush.InfoSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource Brush.Info}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding StatusBadgeText}"
FontSize="{StaticResource Font.Size.Sm}"
FontWeight="SemiBold"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Il nome dell'articolo e' quello che si legge davvero: gli si
lascia lo spazio del piu' lungo, e la griglia scorre in
orizzontale invece di tagliarlo. -->
<DataGridTextColumn Header="Nome" Binding="{Binding Name}"
Width="Auto" MinWidth="240" SortMemberPath="Name"/>
<DataGridTextColumn Header="€" Binding="{Binding PriceDisplay}"
Width="Auto" MinWidth="56" SortMemberPath="SortPrice">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Header="Timer" Width="Auto" MinWidth="60"
SortMemberPath="SortRemainingMs">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding TimerDisplay}" HorizontalAlignment="Right">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding TimerUrgency}" Value="warn">
<Setter Property="Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
<DataTrigger Binding="{Binding TimerUrgency}" Value="danger">
<Setter Property="Foreground" Value="{DynamicResource Brush.Danger}"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Ultimo" Width="Auto" MinWidth="88"
SortMemberPath="LastBidder">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding LastBidder}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMyBid}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Success}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Punt." Binding="{Binding MyClicks}"
Width="Auto" MinWidth="42" SortMemberPath="MyClicks">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Header="Ping" Width="Auto" MinWidth="40"
SortMemberPath="SortPing">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding LatencyDisplay}" HorizontalAlignment="Right">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding PingQuality}" Value="ok">
<Setter Property="Foreground" Value="{DynamicResource Brush.Success}"/>
</DataTrigger>
<DataTrigger Binding="{Binding PingQuality}" Value="warn">
<Setter Property="Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
<DataTrigger Binding="{Binding PingQuality}" Value="danger">
<Setter Property="Foreground" Value="{DynamicResource Brush.Danger}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Azioni" Width="Auto" CanUserSort="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Punta adesso"
Command="{Binding DataContext.GridBidCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE7C9;"/>
</Button>
<Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Success}"
ToolTip="Attiva: segue e punta"
IsEnabled="{Binding CanStart}"
Command="{Binding DataContext.GridStartCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE768;"/>
</Button>
<Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Warning}"
ToolTip="Osserva: segue senza puntare"
IsEnabled="{Binding CanPause}"
Command="{Binding DataContext.GridPauseCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE769;"/>
</Button>
<Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ferma"
IsEnabled="{Binding CanStop}"
Command="{Binding DataContext.GridStopCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE71A;"/>
</Button>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
Background="Transparent" ResizeBehavior="PreviousAndNext"/>
<!-- Log globale -->
<Border Grid.Column="2" Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Padding="12,4,6,4" BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1">
<Grid>
<TextBlock Text="Log" FontWeight="SemiBold"
FontSize="{StaticResource Font.Size.Base}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Button x:Name="ClearGlobalLogButton" Style="{StaticResource IconButton}"
HorizontalAlignment="Right" ToolTip="Pulisci il log"
Click="ClearGlobalLogButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;" FontSize="13"/>
</Button>
</Grid>
</Border>
<RichTextBox Grid.Row="1" x:Name="LogBox" IsReadOnly="True"
Background="Transparent" BorderThickness="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"/>
</Grid>
</Border>
</Grid>
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
Background="Transparent" ResizeBehavior="PreviousAndNext"/>
<!-- ═══════════════ DETTAGLI ASTA SELEZIONATA ═══════════════ -->
<Border Grid.Row="3" Margin="8,0,8,8" Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Intestazione -->
<Border Grid.Row="0" Padding="12,8,8,4">
<Grid>
<TextBlock x:Name="SelectedAuctionName" Text="Nessuna asta selezionata"
FontWeight="SemiBold" FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
Margin="0,0,150,0"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="OpenAuctionInternalButton" Style="{StaticResource IconButton}"
ToolTip="Apri nel browser integrato"
Click="OpenAuctionInternalButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE774;" FontSize="13"/>
</Button>
<Button x:Name="OpenAuctionExternalButton" Style="{StaticResource IconButton}"
ToolTip="Apri su Bidoo nel browser di sistema"
Click="OpenAuctionExternalButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8A7;" FontSize="13"/>
</Button>
<Button x:Name="CopyAuctionUrlButton" Style="{StaticResource IconButton}"
ToolTip="Copia l'indirizzo dell'asta"
Click="CopyAuctionUrlButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8C8;" FontSize="13"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Schede -->
<Border Grid.Row="1" BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
<RadioButton x:Name="TabAuto" Style="{StaticResource DetailTab}" GroupName="DetailTabs"
IsChecked="True" Content="Puntate automatiche"/>
<RadioButton x:Name="TabProduct" Style="{StaticResource DetailTab}" GroupName="DetailTabs"
Content="Prodotto"/>
<RadioButton x:Name="TabBids" Style="{StaticResource DetailTab}" GroupName="DetailTabs"
Content="Puntate"/>
<RadioButton x:Name="TabBidders" Style="{StaticResource DetailTab}" GroupName="DetailTabs"
Content="Puntatori"/>
<RadioButton x:Name="TabLog" Style="{StaticResource DetailTab}" GroupName="DetailTabs"
Content="Log"/>
</StackPanel>
</Border>
<!-- Corpo delle schede -->
<Grid Grid.Row="2">
<!-- ── Puntate automatiche ── -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
Visibility="{Binding IsChecked, ElementName=TabAuto, Converter={StaticResource BoolToVis}}">
<StackPanel Margin="12,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo puntata (ms)"
ToolTip="Quanti millisecondi prima della scadenza parte la puntata. È il parametro che decide quanto costa vincere: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato.
Il 75,3% delle puntate avversarie e' automatica e scatta a 2 secondi esatti: sotto quella soglia resta solo chi punta a mano, e una puntata manuale vince 3,6 volte piu' spesso di una automatica (178 puntate per vittoria contro 634, su 5410 aste).
Predefinito 500 ms: circa nove volte il ping misurato (47-78 ms). Piu' in basso non conviene, perche' Bidoo dichiara la scadenza al secondo intero."
Style="{StaticResource FieldLabel}"/>
<TextBox Grid.Row="1" Grid.Column="0" x:Name="SelectedBidBeforeDeadlineMs"
Margin="0,0,10,2" Text="500"
TextChanged="SelectedBidBeforeDeadlineMs_TextChanged"
ToolTip="Quanti millisecondi prima della scadenza parte la puntata. È il parametro che decide quanto costa vincere: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato.
Il 75,3% delle puntate avversarie e' automatica e scatta a 2 secondi esatti: sotto quella soglia resta solo chi punta a mano, e una puntata manuale vince 3,6 volte piu' spesso di una automatica (178 puntate per vittoria contro 634, su 5410 aste).
Predefinito 500 ms: circa nove volte il ping misurato (47-78 ms). Piu' in basso non conviene, perche' Bidoo dichiara la scadenza al secondo intero."/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="Prezzo minimo €"
ToolTip="Sotto questo prezzo non puntare. Serve a lasciar salire un'asta prima di entrare. 0 = nessun limite."
Style="{StaticResource FieldLabel}"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="SelectedMinPrice"
Margin="0,0,10,2" Text="0"
TextChanged="SelectedMinPrice_TextChanged"
ToolTip="Sotto questo prezzo non puntare. Serve a lasciar salire un'asta prima di entrare. 0 = nessun limite."/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="Prezzo massimo €"
ToolTip="Oltre questo prezzo lascia perdere l'asta. È un limite rigido: il motore non punta più, qualunque cosa dicano le altre strategie. 0 = nessun limite."
Style="{StaticResource FieldLabel}"/>
<TextBox Grid.Row="1" Grid.Column="2" x:Name="SelectedMaxPrice"
Margin="0,0,10,2" Text="0"
TextChanged="SelectedMaxPrice_TextChanged"
ToolTip="Oltre questo prezzo lascia perdere l'asta. È un limite rigido: il motore non punta più, qualunque cosa dicano le altre strategie. 0 = nessun limite."/>
<TextBlock Grid.Row="0" Grid.Column="3" Text="Puntate massime"
ToolTip="Quante puntate al massimo spendere su questa singola asta. 0 = illimitate."
Style="{StaticResource FieldLabel}"/>
<TextBox Grid.Row="1" Grid.Column="3" x:Name="SelectedMaxClicks"
Margin="0,0,0,2" Text="0"
TextChanged="SelectedMaxClicks_TextChanged"
ToolTip="Quante puntate al massimo spendere su questa singola asta. 0 = illimitate."/>
</Grid>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Spesa massima €"
ToolTip="Quanto sei disposto a spendere in puntate su QUESTA asta, in euro. È il fratello di «Puntate massime» detto in denaro invece che in conteggio.&#10;&#10;0 = nessun tetto in euro."
Style="{StaticResource FieldLabel}"/>
<TextBox Grid.Row="1" Grid.Column="0" x:Name="SelectedMaxSpend"
Margin="0,0,10,2" Text="0"
ToolTip="Quanto sei disposto a spendere in puntate su QUESTA asta, in euro. 0 = nessun tetto."
TextChanged="SelectedMaxSpend_TextChanged"/>
<CheckBox Grid.Row="1" Grid.Column="2" x:Name="SelectedStopAtBreakEven"
VerticalAlignment="Center" Margin="4,0,0,2"
Content="Fermati quando vincere costerebbe più del valore"
ToolTip="Prima di ogni puntata il motore calcola quanto costerebbe aggiudicarsi l'articolo adesso: prezzo dell'asta più le puntate già spese, più la spedizione. Se supera il valore del prodotto, smette.&#10;&#10;È il controllo che distingue «vincere» da «vincere senza perderci». Toglilo solo per un articolo che vuoi comunque, a qualunque prezzo."
Checked="SelectedStopAtBreakEven_Changed"
Unchecked="SelectedStopAtBreakEven_Changed"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,2,0,12">
<Button x:Name="ResetSettingsButton" Content="Ripristina predefiniti"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="16,0,0,0" Click="ResetSettingsButton_Click"/>
</StackPanel>
<TextBlock Text="Indirizzo" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="SelectedAuctionUrl" IsReadOnly="True" Margin="0,0,0,12"/>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="10" Padding="12,8">
<StackPanel Orientation="Horizontal">
<StackPanel Margin="0,0,26,0">
<TextBlock Text="Rilanci" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="StatResets" Text="0" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<StackPanel Margin="0,0,26,0">
<TextBlock Text="Tue puntate" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="StatMyBids" Text="0" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Success}"/>
</StackPanel>
<StackPanel Margin="0,0,26,0">
<TextBlock Text="Ping medio" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="StatPing" Text="—" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<StackPanel Margin="0,0,26,0">
<TextBlock Text="Interrogazioni" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="StatPolls" Text="0" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<StackPanel>
<TextBlock Text="Errori di rete" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="StatPollErrors" Text="0" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ── Prodotto ── -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
Visibility="{Binding IsChecked, ElementName=TabProduct, Converter={StaticResource BoolToVis}}">
<StackPanel Margin="12,10">
<WrapPanel Margin="0,0,0,12">
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="10"
Padding="14,10" Margin="0,0,10,10" MinWidth="130">
<StackPanel>
<TextBlock Text="Valore prodotto" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="ProductBuyNowPriceText" Text="—"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</Border>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="10"
Padding="14,10" Margin="0,0,10,10" MinWidth="130">
<StackPanel>
<TextBlock Text="Spedizione" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="ProductShippingCostText" Text="—"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</Border>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="10"
Padding="14,10" Margin="0,0,10,10" MinWidth="150">
<StackPanel>
<TextBlock Text="Costo totale se vinci" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="ProductTotalCostText" Text="—"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</Border>
<Border x:Name="VerdictBox" Background="{DynamicResource Brush.SurfaceAlt}"
CornerRadius="10" Padding="14,10" Margin="0,0,10,10" MinWidth="170">
<StackPanel>
<TextBlock x:Name="VerdictLabel" Text="Convenienza"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock x:Name="VerdictText" Text="—"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</Border>
</WrapPanel>
<TextBlock x:Name="ProductWinLimitText" Style="{StaticResource Hint}"
Foreground="{DynamicResource Brush.Warning}" Text=""/>
<Button x:Name="RefreshProductInfoButton" Content="Aggiorna scheda prodotto"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
HorizontalAlignment="Left"
Click="RefreshProductInfoButton_Click"/>
</StackPanel>
</ScrollViewer>
<!-- ── Puntate ── -->
<Grid Visibility="{Binding IsChecked, ElementName=TabBids, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" x:Name="BidHistoryCount" Text="Nessuna puntata registrata."
Margin="12,8" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
<DataGrid Grid.Row="1" x:Name="BidHistoryGrid" AutoGenerateColumns="False"
IsReadOnly="True" Background="Transparent" Margin="4,0,4,4">
<DataGrid.Columns>
<DataGridTextColumn Header="Utente" Binding="{Binding Username}" Width="*"/>
<DataGridTextColumn Header="Prezzo" Binding="{Binding PriceFormatted}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Ora" Binding="{Binding TimeFormatted}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Tipo" Binding="{Binding BidType}" Width="Auto" MinWidth="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
<!-- ── Puntatori ── -->
<Grid Visibility="{Binding IsChecked, ElementName=TabBidders, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="12,8">
<TextBlock x:Name="SelectedAuctionBiddersCount" Text="Nessun dato sui puntatori."
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}" VerticalAlignment="Center"/>
<Button x:Name="ClearBiddersButton" Content="Azzera"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
HorizontalAlignment="Right" Click="ClearBiddersButton_Click"/>
</Grid>
<DataGrid Grid.Row="1" x:Name="SelectedAuctionBiddersGrid" AutoGenerateColumns="False"
IsReadOnly="True" Background="Transparent" Margin="4,0,4,4">
<DataGrid.Columns>
<DataGridTextColumn Header="Utente" Binding="{Binding Username}" Width="*"/>
<DataGridTextColumn Header="Puntate" Binding="{Binding BidCount}" Width="Auto" MinWidth="66"/>
<DataGridTextColumn Header="Quota" Binding="{Binding ShareDisplay}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Ultima" Binding="{Binding LastBidDisplay}" Width="Auto" MinWidth="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
<!-- ── Log dell'asta ── -->
<Grid Visibility="{Binding IsChecked, ElementName=TabLog, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Button Grid.Row="0" x:Name="ClearLogButton" Content="Pulisci"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
HorizontalAlignment="Right" Margin="12,8"
Click="ClearLogButton_Click"/>
<RichTextBox Grid.Row="1" x:Name="SelectedAuctionLog" IsReadOnly="True"
Background="Transparent" BorderThickness="0" Margin="8,0,8,8"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"/>
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,605 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace AutoBidder.Controls
{
/// <summary>
/// Interaction logic for AuctionMonitorControl.xaml
/// </summary>
public partial class AuctionMonitorControl : UserControl
{
public AuctionMonitorControl()
{
InitializeComponent();
}
// Dependency Properties per Commands (da bindare al MainWindow)
public static readonly DependencyProperty GridStartCommandProperty =
DependencyProperty.Register(nameof(GridStartCommand), typeof(ICommand), typeof(AuctionMonitorControl));
public static readonly DependencyProperty GridPauseCommandProperty =
DependencyProperty.Register(nameof(GridPauseCommand), typeof(ICommand), typeof(AuctionMonitorControl));
public static readonly DependencyProperty GridStopCommandProperty =
DependencyProperty.Register(nameof(GridStopCommand), typeof(ICommand), typeof(AuctionMonitorControl));
public static readonly DependencyProperty GridBidCommandProperty =
DependencyProperty.Register(nameof(GridBidCommand), typeof(ICommand), typeof(AuctionMonitorControl));
public ICommand? GridStartCommand
{
get => (ICommand?)GetValue(GridStartCommandProperty);
set => SetValue(GridStartCommandProperty, value);
}
public ICommand? GridPauseCommand
{
get => (ICommand?)GetValue(GridPauseCommandProperty);
set => SetValue(GridPauseCommandProperty, value);
}
public ICommand? GridStopCommand
{
get => (ICommand?)GetValue(GridStopCommandProperty);
set => SetValue(GridStopCommandProperty, value);
}
public ICommand? GridBidCommand
{
get => (ICommand?)GetValue(GridBidCommandProperty);
set => SetValue(GridBidCommandProperty, value);
}
// Event handlers - these will bubble up to MainWindow
private void StartButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(StartClickedEvent, this));
}
private void PauseAllButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(PauseAllClickedEvent, this));
}
private void StopButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(StopClickedEvent, this));
}
private void LearningToggle_Changed(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(LearningToggledEvent, this));
}
public static readonly RoutedEvent LearningToggledEvent = EventManager.RegisterRoutedEvent(
"LearningToggled", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public event RoutedEventHandler LearningToggled
{
add { AddHandler(LearningToggledEvent, value); }
remove { RemoveHandler(LearningToggledEvent, value); }
}
private void AddUrlButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(AddUrlClickedEvent, this));
}
private void RemoveUrlButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(RemoveUrlClickedEvent, this));
}
private void RemoveAllButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(RemoveAllClickedEvent, this));
}
private void RemoveFinishedButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(RemoveFinishedClickedEvent, this));
}
/// <summary>
/// Aggiorna i contatori della barra strumenti. Li calcola il chiamante, che ha
/// l'elenco completo: qui si presentano soltanto.
/// </summary>
public void UpdateCounters(int total, int active, int watch, int stopped, int won, int lost)
{
PillTotal.Text = total == 1 ? "1 asta" : $"{total} aste";
PillActive.Text = $"{active} attive";
PillWatch.Text = $"{watch} osserva";
PillStopped.Text = $"{stopped} ferme";
PillWon.Text = $"{won} vinte";
PillLost.Text = $"{lost} perse";
}
/// <summary>
/// Stato del motore: traffico prodotto, aggancio all'orologio del server e ritardo
/// medio di rete. Tutti e tre i valori arrivano già misurati dal chiamante.
/// </summary>
public void UpdateEngineStatus(long requestsSent, bool clockSynced, int clockSamples, double avgPingMs)
{
RequestsText.Text = requestsSent.ToString("N0");
// Senza campioni non c'è nulla da dire: meglio un trattino di un numero finto.
if (clockSamples == 0)
{
ClockText.Text = "—";
SetClockTone("Brush.TextFaint");
ClockPill.ToolTip =
"Nessuna misura: il motore non sta interrogando Bidoo.\n" +
"Il pallino diventa verde quando l'orologio è agganciato e la rete risponde in fretta.";
return;
}
ClockText.Text = avgPingMs > 0 ? $"{avgPingMs:F0} ms" : "—";
var tone = !clockSynced ? "Brush.Warning" : PingTone(avgPingMs);
SetClockTone(tone);
var sync = clockSynced
? $"Orologio agganciato al server ({clockSamples} campioni)."
: $"Orologio in aggancio: {clockSamples} campioni su 3.";
ClockPill.ToolTip =
$"{sync}\n" +
$"Ritardo medio di rete: {avgPingMs:F0} ms — {PingVerdict(avgPingMs)}.\n" +
"Tieni l'anticipo puntata sopra questo valore.";
}
/// <summary>
/// Soglie del ritardo di rete. Sono tarate su ciò che serve al motore: con un
/// anticipo tipico di qualche centinaio di millisecondi, oltre i 400 ms di
/// round-trip il margine per arrivare in tempo si assottiglia davvero.
/// </summary>
private static string PingTone(double ms) => ms switch
{
<= 0 => "Brush.TextFaint",
< 150 => "Brush.Success",
< 400 => "Brush.Warning",
_ => "Brush.Danger"
};
private static string PingVerdict(double ms) => ms switch
{
<= 0 => "non misurato",
< 150 => "rete pronta",
< 400 => "rete lenta, alza l'anticipo",
_ => "rete molto lenta, puntate a rischio"
};
private void SetClockTone(string brushKey)
{
ClockDot.SetResourceReference(ForegroundProperty, brushKey);
ClockText.SetResourceReference(ForegroundProperty, brushKey);
}
/// <summary>
/// Stato del conto: puntate, credito e vincite da confermare.
///
/// <para>I valori arrivano annullabili di proposito: finché non si è connessi, o
/// finché una risposta non è arrivata, la barra mostra un trattino invece di uno
/// zero. Uno zero inventato è peggio di un dato mancante — è esattamente l'errore
/// per cui le aste da confermare risultavano sempre nessuna.</para>
/// </summary>
public void UpdateAccountStatus(int? remainingBids, decimal? shopCredit, int? auctionsToConfirm)
{
RemainingBidsText.Text = remainingBids?.ToString("N0") ?? "—";
ShopCreditText.Text = shopCredit.HasValue ? $"{shopCredit.Value:N2} €" : "— €";
if (auctionsToConfirm is > 0)
{
BannerAsteDaRiscattare.Text = auctionsToConfirm.Value.ToString("N0");
ToConfirmPill.Visibility = Visibility.Visible;
ToConfirmPill.ToolTip = auctionsToConfirm.Value == 1
? "1 asta vinta in attesa di conferma su Bidoo"
: $"{auctionsToConfirm.Value} aste vinte in attesa di conferma su Bidoo";
}
else
{
ToConfirmPill.Visibility = Visibility.Collapsed;
}
}
/// <summary>Contatori del motore relativi alla sola asta selezionata.</summary>
public void UpdateSelectedStats(int resets, int myBids, double avgPingMs, long polls, long pollErrors)
{
StatResets.Text = resets.ToString();
StatMyBids.Text = myBids.ToString();
StatPing.Text = avgPingMs > 0 ? $"{avgPingMs:F0} ms" : "—";
StatPolls.Text = polls.ToString("N0");
StatPollErrors.Text = pollErrors.ToString("N0");
StatPollErrors.SetResourceReference(ForegroundProperty,
pollErrors > 0 ? "Brush.Warning" : "Brush.Text");
}
/// <summary>Verdetto di convenienza nella scheda Prodotto.</summary>
public void SetVerdict(string label, string value, string tone)
{
VerdictLabel.Text = label;
VerdictText.Text = value;
VerdictText.SetResourceReference(ForegroundProperty, tone switch
{
"ok" => "Brush.Success",
"danger" => "Brush.Danger",
_ => "Brush.Text"
});
}
private void MultiAuctionsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Forza il focus sul DataGrid quando viene selezionata una riga
// Questo assicura che il tasto Canc venga catturato
if (sender is DataGrid grid && grid.SelectedItem != null)
{
// Attende che il rendering sia completo prima di dare il focus
grid.Dispatcher.BeginInvoke(new Action(() =>
{
if (!grid.IsFocused)
{
grid.Focus();
System.Diagnostics.Debug.WriteLine("[FOCUS] DataGrid ora ha il focus keyboard");
}
}), System.Windows.Threading.DispatcherPriority.Background);
}
RaiseEvent(new RoutedEventArgs(AuctionSelectionChangedEvent, this));
}
// Usato PreviewKeyDown invece di KeyDown per catturare l'evento prima che venga consumato
private void MultiAuctionsGrid_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Delete && MultiAuctionsGrid.SelectedItem != null)
{
System.Diagnostics.Debug.WriteLine("[DELETE KEY] Tasto Canc premuto su asta selezionata");
// Lancia direttamente l'evento senza chiedere conferma
// La conferma verr mostrata dal gestore RemoveUrlButton_Click
System.Diagnostics.Debug.WriteLine("[DELETE KEY] Lancio evento RemoveUrlClicked");
RaiseEvent(new RoutedEventArgs(RemoveUrlClickedEvent, this));
// Previeni che l'evento venga gestito da altri controlli
e.Handled = true;
}
// NUOVO: Gestione esplicita frecce Su/Gi per navigazione
else if (e.Key == Key.Up && MultiAuctionsGrid.Items.Count > 0)
{
int currentIndex = MultiAuctionsGrid.SelectedIndex;
if (currentIndex > 0)
{
MultiAuctionsGrid.SelectedIndex = currentIndex - 1;
MultiAuctionsGrid.ScrollIntoView(MultiAuctionsGrid.SelectedItem);
e.Handled = true; // Previeni ridimensionamento pannelli
}
}
else if (e.Key == Key.Down && MultiAuctionsGrid.Items.Count > 0)
{
int currentIndex = MultiAuctionsGrid.SelectedIndex;
if (currentIndex < MultiAuctionsGrid.Items.Count - 1)
{
MultiAuctionsGrid.SelectedIndex = currentIndex + 1;
MultiAuctionsGrid.ScrollIntoView(MultiAuctionsGrid.SelectedItem);
e.Handled = true; // Previeni ridimensionamento pannelli
}
}
}
private void MoveUpButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(MoveUpClickedEvent, this));
}
private void MoveDownButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(MoveDownClickedEvent, this));
}
private void CopyAuctionUrlButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(CopyUrlClickedEvent, this));
}
private void ResetSettingsButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ResetSettingsClickedEvent, this));
}
private void ClearBiddersButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ClearBiddersClickedEvent, this));
}
private void ClearLogButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ClearLogClickedEvent, this));
}
private void ClearGlobalLogButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ClearGlobalLogClickedEvent, this));
}
private void OpenAuctionInternalButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(OpenAuctionInternalClickedEvent, this));
}
private void OpenAuctionExternalButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(OpenAuctionExternalClickedEvent, this));
}
private void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(RefreshProductInfoClickedEvent, this));
}
private void ConnectionStatusButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ConnectionStatusClickedEvent, this));
}
private void SelectedBidBeforeDeadlineMs_TextChanged(object sender, TextChangedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BidBeforeDeadlineMsChangedEvent, this));
}
private void SelectedMinPrice_TextChanged(object sender, TextChangedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(MinPriceChangedEvent, this));
}
private void SelectedMaxPrice_TextChanged(object sender, TextChangedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(MaxPriceChangedEvent, this));
}
private void SelectedMaxClicks_TextChanged(object sender, TextChangedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(MaxClicksChangedEvent, this));
}
private void SelectedMaxSpend_TextChanged(object sender, TextChangedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(MaxSpendChangedEvent, this));
}
private void SelectedStopAtBreakEven_Changed(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BreakEvenChangedEvent, this));
}
// Routed Events
public static readonly RoutedEvent MaxSpendChangedEvent = EventManager.RegisterRoutedEvent(
"MaxSpendChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent BreakEvenChangedEvent = EventManager.RegisterRoutedEvent(
"BreakEvenChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent StartClickedEvent = EventManager.RegisterRoutedEvent(
"StartClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent PauseAllClickedEvent = EventManager.RegisterRoutedEvent(
"PauseAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent StopClickedEvent = EventManager.RegisterRoutedEvent(
"StopClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent AddUrlClickedEvent = EventManager.RegisterRoutedEvent(
"AddUrlClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent RemoveUrlClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveUrlClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent RemoveFinishedClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveFinishedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent RemoveAllClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent AuctionSelectionChangedEvent = EventManager.RegisterRoutedEvent(
"AuctionSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent CopyUrlClickedEvent = EventManager.RegisterRoutedEvent(
"CopyUrlClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ResetSettingsClickedEvent = EventManager.RegisterRoutedEvent(
"ResetSettingsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ClearBiddersClickedEvent = EventManager.RegisterRoutedEvent(
"ClearBiddersClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ClearLogClickedEvent = EventManager.RegisterRoutedEvent(
"ClearLogClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ClearGlobalLogClickedEvent = EventManager.RegisterRoutedEvent(
"ClearGlobalLogClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent BidBeforeDeadlineMsChangedEvent = EventManager.RegisterRoutedEvent(
"BidBeforeDeadlineMsChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent MinPriceChangedEvent = EventManager.RegisterRoutedEvent(
"MinPriceChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent MaxPriceChangedEvent = EventManager.RegisterRoutedEvent(
"MaxPriceChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent MaxClicksChangedEvent = EventManager.RegisterRoutedEvent(
"MaxClicksChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent OpenAuctionInternalClickedEvent = EventManager.RegisterRoutedEvent(
"OpenAuctionInternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent OpenAuctionExternalClickedEvent = EventManager.RegisterRoutedEvent(
"OpenAuctionExternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent RefreshProductInfoClickedEvent = EventManager.RegisterRoutedEvent(
"RefreshProductInfoClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ConnectionStatusClickedEvent = EventManager.RegisterRoutedEvent(
"ConnectionStatusClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
// NUOVO: Eventi per riordinamento aste
public static readonly RoutedEvent MoveUpClickedEvent = EventManager.RegisterRoutedEvent(
"MoveUpClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent MoveDownClickedEvent = EventManager.RegisterRoutedEvent(
"MoveDownClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public event RoutedEventHandler StartClicked
{
add { AddHandler(StartClickedEvent, value); }
remove { RemoveHandler(StartClickedEvent, value); }
}
public event RoutedEventHandler PauseAllClicked
{
add { AddHandler(PauseAllClickedEvent, value); }
remove { RemoveHandler(PauseAllClickedEvent, value); }
}
public event RoutedEventHandler StopClicked
{
add { AddHandler(StopClickedEvent, value); }
remove { RemoveHandler(StopClickedEvent, value); }
}
public event RoutedEventHandler AddUrlClicked
{
add { AddHandler(AddUrlClickedEvent, value); }
remove { RemoveHandler(AddUrlClickedEvent, value); }
}
public event RoutedEventHandler RemoveUrlClicked
{
add { AddHandler(RemoveUrlClickedEvent, value); }
remove { RemoveHandler(RemoveUrlClickedEvent, value); }
}
public event RoutedEventHandler RemoveAllClicked
{
add { AddHandler(RemoveAllClickedEvent, value); }
remove { RemoveHandler(RemoveAllClickedEvent, value); }
}
public event RoutedEventHandler RemoveFinishedClicked
{
add { AddHandler(RemoveFinishedClickedEvent, value); }
remove { RemoveHandler(RemoveFinishedClickedEvent, value); }
}
public event RoutedEventHandler AuctionSelectionChanged
{
add { AddHandler(AuctionSelectionChangedEvent, value); }
remove { RemoveHandler(AuctionSelectionChangedEvent, value); }
}
public event RoutedEventHandler CopyUrlClicked
{
add { AddHandler(CopyUrlClickedEvent, value); }
remove { RemoveHandler(CopyUrlClickedEvent, value); }
}
public event RoutedEventHandler ResetSettingsClicked
{
add { AddHandler(ResetSettingsClickedEvent, value); }
remove { RemoveHandler(ResetSettingsClickedEvent, value); }
}
public event RoutedEventHandler ClearBiddersClicked
{
add { AddHandler(ClearBiddersClickedEvent, value); }
remove { RemoveHandler(ClearBiddersClickedEvent, value); }
}
public event RoutedEventHandler ClearLogClicked
{
add { AddHandler(ClearLogClickedEvent, value); }
remove { RemoveHandler(ClearLogClickedEvent, value); }
}
public event RoutedEventHandler ClearGlobalLogClicked
{
add { AddHandler(ClearGlobalLogClickedEvent, value); }
remove { RemoveHandler(ClearGlobalLogClickedEvent, value); }
}
public event RoutedEventHandler BidBeforeDeadlineMsChanged
{
add { AddHandler(BidBeforeDeadlineMsChangedEvent, value); }
remove { RemoveHandler(BidBeforeDeadlineMsChangedEvent, value); }
}
public event RoutedEventHandler MinPriceChanged
{
add { AddHandler(MinPriceChangedEvent, value); }
remove { RemoveHandler(MinPriceChangedEvent, value); }
}
public event RoutedEventHandler MaxPriceChanged
{
add { AddHandler(MaxPriceChangedEvent, value); }
remove { RemoveHandler(MaxPriceChangedEvent, value); }
}
public event RoutedEventHandler MaxClicksChanged
{
add { AddHandler(MaxClicksChangedEvent, value); }
remove { RemoveHandler(MaxClicksChangedEvent, value); }
}
public event RoutedEventHandler MaxSpendChanged
{
add { AddHandler(MaxSpendChangedEvent, value); }
remove { RemoveHandler(MaxSpendChangedEvent, value); }
}
public event RoutedEventHandler BreakEvenChanged
{
add { AddHandler(BreakEvenChangedEvent, value); }
remove { RemoveHandler(BreakEvenChangedEvent, value); }
}
public event RoutedEventHandler OpenAuctionInternalClicked
{
add { AddHandler(OpenAuctionInternalClickedEvent, value); }
remove { RemoveHandler(OpenAuctionInternalClickedEvent, value); }
}
public event RoutedEventHandler OpenAuctionExternalClicked
{
add { AddHandler(OpenAuctionExternalClickedEvent, value); }
remove { RemoveHandler(OpenAuctionExternalClickedEvent, value); }
}
public event RoutedEventHandler RefreshProductInfoClicked
{
add { AddHandler(RefreshProductInfoClickedEvent, value); }
remove { RemoveHandler(RefreshProductInfoClickedEvent, value); }
}
public event RoutedEventHandler ConnectionStatusClicked
{
add { AddHandler(ConnectionStatusClickedEvent, value); }
remove { RemoveHandler(ConnectionStatusClickedEvent, value); }
}
// NUOVO: Handler per eventi riordinamento
public event RoutedEventHandler MoveUpClicked
{
add { AddHandler(MoveUpClickedEvent, value); }
remove { RemoveHandler(MoveUpClickedEvent, value); }
}
public event RoutedEventHandler MoveDownClicked
{
add { AddHandler(MoveDownClickedEvent, value); }
remove { RemoveHandler(MoveDownClickedEvent, value); }
}
}
}
+318
View File
@@ -0,0 +1,318 @@
<UserControl x:Class="AutoBidder.Controls.BrowserControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<!-- Scheda di una singola asta del catalogo -->
<DataTemplate x:Key="AuctionCardTemplate">
<Border Style="{StaticResource AuctionCard}" Width="250">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Immagine prodotto: arriva dalla rete, quindi puo' mancare -->
<Border Grid.Row="0" Height="110" CornerRadius="8"
Background="{DynamicResource Brush.SurfaceAlt}" Margin="0,0,0,8">
<Image Source="{Binding ImageUrl}" Stretch="Uniform" Margin="6"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Border>
<TextBlock Grid.Row="1" Text="{Binding Name}" ToolTip="{Binding Name}"
FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
MaxHeight="34" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}" Margin="0,0,0,6"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="{Binding PriceDisplay}" FontWeight="Bold"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,10,0"/>
<TextBlock Text="{Binding TimerDisplay}" Margin="0,0,10,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding TimerUrgency}" Value="warn">
<Setter Property="Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
<DataTrigger Binding="{Binding TimerUrgency}" Value="danger">
<Setter Property="Foreground" Value="{DynamicResource Brush.Danger}"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding BuyNowDisplay}"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"
ToolTip="Valore del prodotto (Compra Subito)"/>
</StackPanel>
<Grid Grid.Row="3">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding LastBidderDisplay}"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"
TextTrimming="CharacterEllipsis" MaxWidth="90"/>
<!-- Solo l'icona della mano: la parola "manuale" mangiava spazio
al nome dell'ultimo puntatore, che è il dato che si legge
davvero. Il significato resta nel suggerimento. -->
<Border Background="{DynamicResource Brush.WarningSubtle}" CornerRadius="5"
Padding="4,1" Margin="6,0,0,0"
Visibility="{Binding ManualBadgeVisibility}"
ToolTip="Asta solo manuale: non accetta puntate automatiche">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE815;" FontSize="10"
Foreground="{DynamicResource Brush.Warning}"/>
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<!-- Segui il prodotto: le aste future dello stesso articolo
entreranno nel monitor da sole. -->
<Button Style="{StaticResource MiniIconButton}"
ToolTip="{Binding WatchTooltip}"
Command="{Binding DataContext.CatalogWatchCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="{Binding WatchGlyph}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextFaint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsWatched}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Gold}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<!-- Mette il prodotto nella scheda Prodotti e ci porta sopra:
serve a dargli limiti propri anche senza seguirlo. -->
<Button Style="{StaticResource MiniIconButton}"
ToolTip="{Binding ConfigureTooltip}"
Command="{Binding DataContext.CatalogConfigureCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE713;">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextFaint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsListed}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<Button Style="{StaticResource MiniIconButton}"
ToolTip="Apri su Bidoo"
Command="{Binding DataContext.CatalogOpenCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8A7;"/>
</Button>
<Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Aggiungi al monitor"
Command="{Binding DataContext.CatalogAddCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE710;"/>
</Button>
</StackPanel>
</Grid>
</Grid>
</Border>
</DataTemplate>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Catalogo e Browser ora sono due schede separate nella barra laterale
("Cerca" e "Browser"): l'interruttore interno resta solo come stato
pilotato da fuori, non più visibile all'utente. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
Visibility="Collapsed">
<RadioButton x:Name="ModeCatalogRadio" Style="{StaticResource SegmentedButton}"
GroupName="ExploreMode" IsChecked="True" Content="Catalogo"
Checked="ModeCatalogRadio_Checked"
ToolTip="Griglia delle aste: veloce, senza caricare pagine"/>
<RadioButton x:Name="ModeBrowserRadio" Style="{StaticResource SegmentedButton}"
GroupName="ExploreMode" Content="Browser"
Checked="ModeBrowserRadio_Checked"
ToolTip="Browser Bidoo integrato: serve per il login (il cookie viene importato da solo)"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="8,4"/>
</StackPanel>
<StackPanel Grid.Column="1" x:Name="CatalogToolbarLeft" Orientation="Horizontal"
VerticalAlignment="Center">
<Button x:Name="CatalogRefreshButton" Style="{StaticResource IconButton}"
ToolTip="Ricarica categorie e prezzi" Click="CatalogRefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<CheckBox x:Name="CatalogAutoRefresh" Content="Prezzi in tempo reale"
Margin="10,0,0,0" VerticalAlignment="Center"
Checked="CatalogAutoRefresh_Changed" Unchecked="CatalogAutoRefresh_Changed"
ToolTip="Aggiorna prezzi e timer sul posto. Spento di default: acceso, i valori cambiano mentre stai leggendo."/>
<CheckBox x:Name="CatalogHideManual" Content="Nascondi aste manuali"
Margin="14,0,0,0" VerticalAlignment="Center"
Checked="CatalogFilterChanged" Unchecked="CatalogFilterChanged"
ToolTip="Le aste manuali non accettano puntate automatiche"/>
</StackPanel>
<!-- Barra indirizzi del browser: occupa lo stesso posto dei filtri -->
<Border Grid.Column="1" Grid.ColumnSpan="2" x:Name="BrowserToolbar"
Visibility="Collapsed" Margin="0,0,10,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="BrowserBackButton" Style="{StaticResource IconButton}"
ToolTip="Indietro" Click="BrowserBackButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72B;"/>
</Button>
<Button x:Name="BrowserForwardButton" Style="{StaticResource IconButton}"
ToolTip="Avanti" Click="BrowserForwardButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72A;"/>
</Button>
<Button x:Name="BrowserRefreshButton" Style="{StaticResource IconButton}"
ToolTip="Ricarica" Click="BrowserRefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<Button x:Name="BrowserHomeButton" Style="{StaticResource IconButton}"
ToolTip="Homepage Bidoo" Click="BrowserHomeButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE80F;"/>
</Button>
</StackPanel>
<Border Grid.Column="1" Background="{DynamicResource Brush.Bg}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1"
CornerRadius="8" Margin="8,0,0,0">
<TextBox x:Name="BrowserAddress" VerticalAlignment="Center"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource Brush.Text}" Padding="10,0"
IsReadOnly="True" Cursor="Arrow"
ToolTip="Indirizzo della pagina corrente"/>
</Border>
</Grid>
</Border>
<!-- Campo di ricerca con suggerimento: senza, un riquadro vuoto in barra
non si capisce a cosa serva. -->
<Grid Grid.Column="2" x:Name="CatalogSearchHost" Margin="16,0,10,0"
Width="240" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBox x:Name="CatalogSearchBox"
TextChanged="CatalogSearchBox_TextChanged"
ToolTip="Filtra per nome prodotto"/>
<TextBlock x:Name="CatalogSearchHint" Text="Filtra per nome…"
IsHitTestVisible="False" Margin="10,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.TextFaint}"/>
</Grid>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Pill}">
<TextBlock x:Name="CatalogCountText" Text="0 aste"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Button x:Name="BrowserAddAuctionButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}" Visibility="Collapsed"
ToolTip="Aggiungi al monitor l'asta aperta nel browser"
Click="BrowserAddAuctionButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE710;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Riga vuota: tiene la struttura a tre righe leggibile -->
<Border Grid.Row="1" Height="0"/>
<!-- ═══ Catalogo ═══ -->
<Grid Grid.Row="2" x:Name="CatalogPanel">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Categorie -->
<Border Grid.Column="0" Width="190" Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,1,0">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="6,8">
<ItemsControl x:Name="CatalogCategoryList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<RadioButton Style="{StaticResource CategoryButton}"
GroupName="CatalogCategories"
Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected, Mode=TwoWay}"
Checked="CategoryButton_Checked"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- Schede -->
<Grid Grid.Column="1">
<ScrollViewer x:Name="CatalogScroller" VerticalScrollBarVisibility="Auto" Padding="12,12,2,12">
<ItemsControl x:Name="CatalogItems" ItemTemplate="{StaticResource AuctionCardTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
<!-- Stato vuoto / caricamento -->
<StackPanel x:Name="CatalogEmptyState" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE721;" FontSize="34"
Foreground="{DynamicResource Brush.TextFaint}"/>
<TextBlock x:Name="CatalogEmptyText" Text="Caricamento categorie…"
Margin="0,10,0,0" HorizontalAlignment="Center"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Grid>
</Grid>
<!-- ═══ Browser integrato ═══ -->
<Border Grid.Row="2" x:Name="BrowserPanel" Background="{DynamicResource Brush.Bg}" Visibility="Collapsed">
<wv2:WebView2 x:Name="EmbeddedWebView"
PreviewMouseRightButtonUp="EmbeddedWebView_PreviewMouseRightButtonUp"/>
</Border>
</Grid>
</UserControl>
+322
View File
@@ -0,0 +1,322 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Web.WebView2.Core;
namespace AutoBidder.Controls
{
/// <summary>
/// Interaction logic for BrowserControl.xaml
/// REFACTORED: Gestione semplificata e diretta degli eventi WebView2
/// </summary>
public partial class BrowserControl : UserControl
{
public BrowserControl()
{
InitializeComponent();
// ? NUOVO: Collega eventi NavigationStarting e NavigationCompleted direttamente qui
EmbeddedWebView.NavigationStarting += WebView_NavigationStarting;
EmbeddedWebView.NavigationCompleted += WebView_NavigationCompleted;
}
/// <summary>
/// ? NUOVO: Aggiorna address bar quando inizia la navigazione
/// </summary>
private void WebView_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
try
{
// Aggiorna immediatamente l'address bar con l'URL di destinazione
if (!string.IsNullOrEmpty(e.Uri))
{
BrowserAddress.Text = e.Uri;
}
// Propaga l'evento al MainWindow
var args = new BrowserNavigationEventArgs(BrowserNavigationStartingEvent, this)
{
Uri = e.Uri
};
RaiseEvent(args);
}
catch { }
}
/// <summary>
/// ? NUOVO: Aggiorna address bar quando la navigazione completata
/// </summary>
private void WebView_NavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
try
{
// Aggiorna l'address bar con l'URL finale (dopo eventuali redirect)
var finalUrl = EmbeddedWebView?.Source?.ToString();
if (!string.IsNullOrEmpty(finalUrl))
{
BrowserAddress.Text = finalUrl;
}
// Propaga l'evento al MainWindow
RaiseEvent(new RoutedEventArgs(BrowserNavigationCompletedEvent, this));
}
catch { }
}
private void BrowserBackButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BrowserBackClickedEvent, this));
}
private void BrowserForwardButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BrowserForwardClickedEvent, this));
}
private void BrowserRefreshButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BrowserRefreshClickedEvent, this));
}
private void BrowserHomeButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BrowserHomeClickedEvent, this));
}
private void BrowserAddAuctionButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(BrowserAddAuctionClickedEvent, this));
}
private void EmbeddedWebView_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
}
// ===== MODALITA' ESPLORA =====
private void ModeCatalogRadio_Checked(object sender, RoutedEventArgs e) => ApplyMode(catalog: true);
private void ModeBrowserRadio_Checked(object sender, RoutedEventArgs e) => ApplyMode(catalog: false);
private void ApplyMode(bool catalog)
{
// Chiamato anche durante InitializeComponent, quando gli elementi dichiarati piu'
// in basso nel XAML non esistono ancora. Vanno verificati tutti: fidarsi
// dell'ordine di dichiarazione rende il controllo fragile a ogni riordino.
if (CatalogPanel == null || BrowserPanel == null || BrowserToolbar == null ||
CatalogToolbarLeft == null || CatalogSearchHost == null ||
CatalogCountText == null || BrowserAddAuctionButton == null)
{
return;
}
CatalogPanel.Visibility = catalog ? Visibility.Visible : Visibility.Collapsed;
BrowserPanel.Visibility = catalog ? Visibility.Collapsed : Visibility.Visible;
// I due gruppi di comandi occupano lo stesso spazio: ne vive uno solo per volta.
BrowserToolbar.Visibility = catalog ? Visibility.Collapsed : Visibility.Visible;
CatalogToolbarLeft.Visibility = catalog ? Visibility.Visible : Visibility.Collapsed;
CatalogSearchHost.Visibility = catalog ? Visibility.Visible : Visibility.Collapsed;
CatalogCountText.Visibility = catalog ? Visibility.Visible : Visibility.Collapsed;
BrowserAddAuctionButton.Visibility = catalog ? Visibility.Collapsed : Visibility.Visible;
}
/// <summary>Porta in primo piano il browser integrato (usato dal login e dal catalogo).</summary>
public void ShowBrowser() => ModeBrowserRadio.IsChecked = true;
/// <summary>Porta in primo piano il catalogo nativo (scheda "Cerca").</summary>
public void ShowCatalog() => ModeCatalogRadio.IsChecked = true;
/// <summary>True quando l'utente sta guardando il catalogo nativo.</summary>
public bool IsCatalogMode => ModeCatalogRadio.IsChecked == true;
// ===== CATALOGO =====
/// <summary>
/// Impedisce che il riempimento iniziale dell'elenco categorie faccia partire
/// un caricamento per ogni voce aggiunta.
/// </summary>
private bool _catalogReady;
private void CategoryButton_Checked(object sender, RoutedEventArgs e)
{
if (!_catalogReady) return;
RaiseEvent(new RoutedEventArgs(CatalogCategoryChangedEvent, this));
}
private void CatalogRefreshButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(CatalogRefreshClickedEvent, this));
private void CatalogSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (CatalogSearchHint != null)
{
CatalogSearchHint.Visibility = string.IsNullOrEmpty(CatalogSearchBox.Text)
? Visibility.Visible
: Visibility.Collapsed;
}
RaiseEvent(new RoutedEventArgs(CatalogSearchChangedEvent, this));
}
private void CatalogFilterChanged(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(CatalogSearchChangedEvent, this));
private void CatalogAutoRefresh_Changed(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(CatalogAutoRefreshChangedEvent, this));
/// <summary>Riempie l'elenco categorie senza scatenare un caricamento per voce.</summary>
public void SetCategories(System.Collections.IEnumerable categories)
{
_catalogReady = false;
try { CatalogCategoryList.ItemsSource = categories; }
finally { _catalogReady = true; }
}
/// <summary>Mostra le schede oppure, se non ce ne sono, un messaggio di stato.</summary>
public void SetCatalogItems(System.Collections.IList items, int totalCount)
{
CatalogItems.ItemsSource = items;
CatalogCountText.Text = items.Count == 1 ? "1 asta" : $"{items.Count} aste";
var empty = items.Count == 0;
CatalogEmptyState.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
CatalogScroller.Visibility = empty ? Visibility.Collapsed : Visibility.Visible;
if (empty)
{
CatalogEmptyText.Text = totalCount > 0
? "Nessuna asta corrisponde ai filtri."
: "Nessuna asta in questa categoria.";
}
}
/// <summary>Messaggio a tutto pannello (caricamento in corso, errore di rete...).</summary>
public void SetCatalogMessage(string message)
{
CatalogEmptyState.Visibility = Visibility.Visible;
CatalogScroller.Visibility = Visibility.Collapsed;
CatalogEmptyText.Text = message;
}
public bool AutoRefreshEnabled => CatalogAutoRefresh.IsChecked == true;
/// <summary>
/// Allinea l'interruttore all'impostazione salvata senza far partire un giro di
/// aggiornamento: chi chiama sta ancora costruendo la pagina.
/// </summary>
public void SetAutoRefresh(bool enabled)
{
CatalogAutoRefresh.Checked -= CatalogAutoRefresh_Changed;
CatalogAutoRefresh.Unchecked -= CatalogAutoRefresh_Changed;
try { CatalogAutoRefresh.IsChecked = enabled; }
finally
{
CatalogAutoRefresh.Checked += CatalogAutoRefresh_Changed;
CatalogAutoRefresh.Unchecked += CatalogAutoRefresh_Changed;
}
}
public bool HideManualAuctions => CatalogHideManual.IsChecked == true;
public string SearchText => CatalogSearchBox.Text?.Trim() ?? "";
// Routed Events
public static readonly RoutedEvent BrowserBackClickedEvent = EventManager.RegisterRoutedEvent(
"BrowserBackClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent BrowserForwardClickedEvent = EventManager.RegisterRoutedEvent(
"BrowserForwardClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent BrowserRefreshClickedEvent = EventManager.RegisterRoutedEvent(
"BrowserRefreshClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent BrowserHomeClickedEvent = EventManager.RegisterRoutedEvent(
"BrowserHomeClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent BrowserAddAuctionClickedEvent = EventManager.RegisterRoutedEvent(
"BrowserAddAuctionClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent BrowserNavigationStartingEvent = EventManager.RegisterRoutedEvent(
"BrowserNavigationStarting", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent BrowserNavigationCompletedEvent = EventManager.RegisterRoutedEvent(
"BrowserNavigationCompleted", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public event RoutedEventHandler BrowserBackClicked
{
add { AddHandler(BrowserBackClickedEvent, value); }
remove { RemoveHandler(BrowserBackClickedEvent, value); }
}
public event RoutedEventHandler BrowserForwardClicked
{
add { AddHandler(BrowserForwardClickedEvent, value); }
remove { RemoveHandler(BrowserForwardClickedEvent, value); }
}
public event RoutedEventHandler BrowserRefreshClicked
{
add { AddHandler(BrowserRefreshClickedEvent, value); }
remove { RemoveHandler(BrowserRefreshClickedEvent, value); }
}
public event RoutedEventHandler BrowserHomeClicked
{
add { AddHandler(BrowserHomeClickedEvent, value); }
remove { RemoveHandler(BrowserHomeClickedEvent, value); }
}
public event RoutedEventHandler BrowserAddAuctionClicked
{
add { AddHandler(BrowserAddAuctionClickedEvent, value); }
remove { RemoveHandler(BrowserAddAuctionClickedEvent, value); }
}
// ===== Eventi del catalogo =====
public static readonly RoutedEvent CatalogCategoryChangedEvent = EventManager.RegisterRoutedEvent(
"CatalogCategoryChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent CatalogRefreshClickedEvent = EventManager.RegisterRoutedEvent(
"CatalogRefreshClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent CatalogAutoRefreshChangedEvent = EventManager.RegisterRoutedEvent(
"CatalogAutoRefreshChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public static readonly RoutedEvent CatalogSearchChangedEvent = EventManager.RegisterRoutedEvent(
"CatalogSearchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(BrowserControl));
public event RoutedEventHandler CatalogCategoryChanged
{
add { AddHandler(CatalogCategoryChangedEvent, value); }
remove { RemoveHandler(CatalogCategoryChangedEvent, value); }
}
public event RoutedEventHandler CatalogRefreshClicked
{
add { AddHandler(CatalogRefreshClickedEvent, value); }
remove { RemoveHandler(CatalogRefreshClickedEvent, value); }
}
public event RoutedEventHandler CatalogAutoRefreshChanged
{
add { AddHandler(CatalogAutoRefreshChangedEvent, value); }
remove { RemoveHandler(CatalogAutoRefreshChangedEvent, value); }
}
public event RoutedEventHandler CatalogSearchChanged
{
add { AddHandler(CatalogSearchChangedEvent, value); }
remove { RemoveHandler(CatalogSearchChangedEvent, value); }
}
}
public class BrowserNavigationEventArgs : RoutedEventArgs
{
public string? Uri { get; set; }
public BrowserNavigationEventArgs(RoutedEvent routedEvent, object source) : base(routedEvent, source)
{
}
}
}
+364
View File
@@ -0,0 +1,364 @@
<UserControl x:Class="AutoBidder.Controls.FreeBidsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="CounterCaption" TargetType="TextBlock">
<Setter Property="FontSize" Value="{StaticResource Font.Size.Sm}"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.TextFaint}"/>
<Setter Property="Margin" Value="0,0,0,3"/>
</Style>
<Style x:Key="CounterValue" TargetType="TextBlock">
<Setter Property="FontSize" Value="22"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="RefreshButton" Style="{StaticResource IconButton}"
ToolTip="Aggiorna il saldo puntate" Click="RefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Puntate" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Pill}" ToolTip="Puntate attualmente sul conto">
<TextBlock x:Name="BalanceText" Text="— puntate"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Text}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Stato del riscatto automatico">
<TextBlock x:Name="StatusPillText" Text="riscatto spento"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="CheckNowButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Controlla adesso" Margin="0,0,8,0"
ToolTip="Cerca subito ricompense da riscuotere, senza aspettare il prossimo giro"
Click="CheckNowButton_Click"/>
<Button x:Name="OpenExternalButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="0,0,8,0"
ToolTip="Apre la pagina delle ricompense nel browser predefinito di Windows"
Click="OpenExternalButton_Click">
<StackPanel Orientation="Horizontal">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8A7;" FontSize="11"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="Apri su Bidoo" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button x:Name="OpenInternalButton" Style="{StaticResource IconButton}"
ToolTip="Apri le ricompense nel browser integrato (è lì che arrivano le notifiche)"
Click="OpenInternalButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE774;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="20,16" MaxWidth="1000" HorizontalAlignment="Left">
<!-- ═══ Riscatto automatico ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Riscatto automatico"
Margin="0,0,0,12"
Help="Bidoo lascia in attesa le ricompense dei giorni scorsi. Con questo acceso, l'applicazione controlla a intervalli regolari e riscuote quello che trova, mentre continua a seguire le aste."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Riscuoti da solo le ricompense"
Foreground="{DynamicResource Brush.Text}" Margin="0,8"
VerticalAlignment="Center"/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="AutoClaimCheckBox"
Margin="10,8" VerticalAlignment="Center"
Checked="AutoClaimCheckBox_Changed" Unchecked="AutoClaimCheckBox_Changed"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Controlla ogni (minuti)"
Foreground="{DynamicResource Brush.Text}" Margin="0,8"
VerticalAlignment="Center"
ToolTip="Minimo 5 minuti: le ricompense sono giornaliere"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="CheckMinutesTextBox"
Text="30" Width="90" Margin="10,8" HorizontalAlignment="Left"
LostFocus="CheckMinutesTextBox_LostFocus"/>
</Grid>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8"
Padding="12,10" Margin="0,10,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;" FontSize="13"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,8,0"/>
<TextBlock x:Name="ScheduleText" VerticalAlignment="Center"
Text="Riscatto automatico spento."
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ═══ Contatori ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<Grid Margin="0,0,0,12">
<TextBlock Text="Quanto ho riscattato" Style="{StaticResource BlockTitle}"
Margin="0"/>
<Button x:Name="ResetCountersButton" HorizontalAlignment="Right"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Content="Azzera contatori"
Click="ResetCountersButton_Click"/>
</Grid>
<UniformGrid Columns="4">
<StackPanel Margin="0,0,16,0">
<TextBlock Text="Puntate — questa sessione" Style="{StaticResource CounterCaption}"/>
<TextBlock x:Name="SessionBidsText" Text="0" Style="{StaticResource CounterValue}"
Foreground="{DynamicResource Brush.Accent}"/>
</StackPanel>
<StackPanel Margin="0,0,16,0">
<TextBlock Text="Puntate — oggi" Style="{StaticResource CounterCaption}"/>
<TextBlock x:Name="TodayBidsText" Text="0" Style="{StaticResource CounterValue}"/>
</StackPanel>
<StackPanel Margin="0,0,16,0">
<TextBlock Text="Puntate — da sempre" Style="{StaticResource CounterCaption}"/>
<TextBlock x:Name="TotalBidsText" Text="0" Style="{StaticResource CounterValue}"
Foreground="{DynamicResource Brush.Gold}"/>
</StackPanel>
<StackPanel>
<TextBlock Text="Ricompense riscosse" Style="{StaticResource CounterCaption}"/>
<TextBlock x:Name="TotalClaimedText" Text="0" Style="{StaticResource CounterValue}"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="CountersDetailText" Margin="0,12,0,0"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"
Text="Nessun controllo ancora eseguito."/>
</StackPanel>
</Border>
<!-- ═══ Registro ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<Grid Margin="0,0,0,10">
<TextBlock Text="Registro dei riscatti" Style="{StaticResource BlockTitle}"
Margin="0"/>
<Button x:Name="ClearLogButton" HorizontalAlignment="Right"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Pulisci" Click="ClearLogButton_Click"/>
</Grid>
<Border Background="{DynamicResource Brush.Bg}" CornerRadius="8"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1"
Height="190">
<Grid>
<ListBox x:Name="ActivityList" Background="Transparent"
BorderThickness="0" Margin="4"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="2,1">
<TextBlock Text="{Binding Time}" FontSize="11"
Foreground="{DynamicResource Brush.TextFaint}"
Margin="0,0,10,0" VerticalAlignment="Top"/>
<TextBlock Text="{Binding Message}" FontSize="12"
TextWrapping="Wrap" MaxWidth="760">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsProblem}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock x:Name="ActivityEmptyText"
Text="Nessun riscatto registrato in questa sessione."
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="12"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Grid>
</Border>
</StackPanel>
</Border>
<!-- ═══ Collegamenti promozionali pubblicati ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Puntate gratis dai collegamenti pubblicati"
Margin="0,0,0,12"
Help="Bidoo regala puntate con collegamenti già firmati che i siti di raccolta ripubblicano. L'applicazione legge quella pagina, scarta i collegamenti già presi e apre i nuovi con la tua sessione, uno alla volta. I codici presi restano in memoria: senza, ogni giro riaprirebbe gli stessi."/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<Button x:Name="HarvestButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Cerca collegamenti adesso" Margin="0,0,8,0"
ToolTip="Legge subito la pagina dei collegamenti e riscuote quelli nuovi"
Click="HarvestButton_Click"/>
<Button x:Name="OpenSourceButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri la pagina dei collegamenti" Margin="0,0,8,0"
ToolTip="Apre nel browser integrato la pagina da cui vengono presi i collegamenti"
Click="OpenSourceButton_Click"/>
<Button x:Name="ForgetPromosButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Dimentica i codici presi"
ToolTip="Azzera la memoria dei collegamenti già aperti (serve dopo un cambio di account)"
Click="ForgetPromosButton_Click"/>
</StackPanel>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8"
Padding="12,10" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8A9;" FontSize="13"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,8,0"/>
<TextBlock x:Name="HarvestStatusText" VerticalAlignment="Center"
TextWrapping="Wrap"
Text="Nessuna raccolta ancora eseguita."
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ═══ Riscatto da codice o collegamento ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Codice o collegamento singolo"
Margin="0,0,0,12"
Help="Per un collegamento ricevuto per email o per messaggio, che la pagina di raccolta non ha. Incollalo intero: viene aperto con la sessione attiva, come se ci avessi cliccato sopra. Quelli che non portano a Bidoo vengono rifiutati. Un codice da solo di norma non basta — questi indirizzi portano una firma (sign) che non si può ricostruire."/>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="PromoCodeTextBox" Width="420" VerticalAlignment="Center"
KeyDown="PromoCodeTextBox_KeyDown"
ToolTip="Codice promozionale, oppure indirizzo completo del riscatto"/>
<Button x:Name="ClaimPromoButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Riscatta" Margin="8,0,0,0"
Click="ClaimPromoButton_Click"/>
</StackPanel>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8"
Padding="12,10" Margin="0,12,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" x:Name="ConfigPathText" TextWrapping="Wrap"
VerticalAlignment="Center"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"
Text="Indirizzi, intestazioni e parametri del riscatto stanno in site-config.json."/>
<Button Grid.Column="1" x:Name="OpenConfigButton"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Surface}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri i riferimenti" Margin="10,0,0,0"
ToolTip="Apre il file con indirizzi, intestazioni e parametri del riscatto"
Click="OpenConfigButton_Click"/>
</Grid>
</Border>
</StackPanel>
</Border>
<!-- ═══ Cosa resta manuale ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Cosa resta da fare a mano" Style="{StaticResource BlockTitle}"/>
<Border Style="{StaticResource InfoBox}">
<TextBlock TextWrapping="Wrap" FontSize="12" LineHeight="18"
Foreground="{DynamicResource Brush.Text}">
<Run Text="Le puntate consegnate per notifica non passano da qui." FontWeight="Bold"/>
<Run Text=" Bidoo le manda tramite le notifiche push del browser (OneSignal) e per email, con collegamenti personali validi una volta sola: sono legate all'abbonamento del browser, non all'account, quindi non esiste una chiamata da fare con il cookie di sessione. Per quelle apri le ricompense nel browser integrato, concedi le notifiche e lascia la scheda aperta."/>
</TextBlock>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Content="Buoni e voucher" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="0,0,8,0" Click="OpenVouchersButton_Click"/>
<Button Content="Acquista puntate" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="OpenBuyBidsButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
+352
View File
@@ -0,0 +1,352 @@
using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// Scheda Puntate: stato del riscatto automatico, contatori e registro.
///
/// <para>Come gli altri pannelli non conosce né la rete né gli archivi: mostra ciò che
/// gli viene passato e segnala le richieste dell'utente con eventi.</para>
/// </summary>
public partial class FreeBidsControl : UserControl
{
/// <summary>Una riga del registro dei riscatti.</summary>
public sealed record ActivityEntry(string Time, string Message, bool IsProblem);
/// <summary>
/// Il registro è volutamente limitato e solo in memoria: serve a capire cosa è
/// successo mentre si guardava altrove, non a essere uno storico — quello sono i
/// contatori, che invece durano.
/// </summary>
private const int MaxActivityEntries = 200;
private readonly ObservableCollection<ActivityEntry> _activity = new();
/// <summary>Evita che riempire i campi dalle impostazioni scateni un salvataggio.</summary>
private bool _loading;
public FreeBidsControl()
{
InitializeComponent();
ActivityList.ItemsSource = _activity;
}
// ── Aggiornamento dello stato ────────────────────────────────────
public void SetBalance(int? bids) =>
BalanceText.Text = bids.HasValue
? $"{bids.Value} puntate sul conto"
: "saldo non disponibile";
/// <summary>Allinea i campi alle impostazioni salvate senza scatenare eventi.</summary>
public void SetOptions(bool autoClaimEnabled, int checkMinutes)
{
_loading = true;
try
{
AutoClaimCheckBox.IsChecked = autoClaimEnabled;
CheckMinutesTextBox.Text = checkMinutes.ToString();
}
finally { _loading = false; }
}
public bool AutoClaimEnabled => AutoClaimCheckBox.IsChecked == true;
/// <summary>Codice o collegamento digitato per il riscatto manuale.</summary>
public string PromoCode => PromoCodeTextBox.Text?.Trim() ?? "";
/// <summary>Svuota il campo dopo un riscatto andato a buon fine.</summary>
public void ClearPromoCode() => PromoCodeTextBox.Text = "";
/// <summary>
/// Riga di stato della raccolta: da dove legge, quanti codici sono già stati presi e
/// com'è andato l'ultimo giro.
/// </summary>
public void SetHarvestStatus(string sourceUrl, int claimedCodes, string? lastOutcome)
{
var known = claimedCodes == 1
? "1 codice già preso"
: $"{claimedCodes} codici già presi";
HarvestStatusText.Text = lastOutcome == null
? $"Sorgente: {sourceUrl} · {known}."
: $"Sorgente: {sourceUrl} · {known} · ultimo giro: {lastOutcome}";
}
/// <summary>
/// Mostra dove sta il file dei riferimenti, ed eventualmente perché non è stato
/// possibile usarlo: sono i valori che decidono ogni chiamata di riscatto, e sapere
/// che se ne stanno usando altri è la differenza fra correggere il file e cercare un
/// guasto che non c'è.
/// </summary>
public void SetConfigPath(string path, string? problem)
{
ConfigPathText.Text = problem == null
? $"Indirizzi, intestazioni e parametri del riscatto: {path}"
: $"{problem} — {path}";
ConfigPathText.SetResourceReference(TextBlock.ForegroundProperty,
problem == null ? "Brush.TextMuted" : "Brush.Warning");
}
/// <summary>Minuti richiesti, già riportati entro il minimo consentito.</summary>
public int CheckMinutes =>
int.TryParse(CheckMinutesTextBox.Text?.Trim(), out var minutes) ? Math.Max(5, minutes) : 30;
/// <summary>Riga di stato: acceso o spento, e quando tocca al prossimo giro.</summary>
public void SetSchedule(bool running, DateTime? lastCheck, DateTime? nextCheck)
{
StatusPillText.Text = running ? "riscatto attivo" : "riscatto spento";
StatusPillText.SetResourceReference(TextBlock.ForegroundProperty,
running ? "Brush.Success" : "Brush.TextMuted");
if (!running)
{
ScheduleText.Text = "Riscatto automatico spento: puoi comunque usare «Controlla adesso».";
return;
}
var last = lastCheck.HasValue
? $"ultimo controllo alle {lastCheck.Value:HH:mm}"
: "nessun controllo ancora eseguito";
var next = nextCheck.HasValue
? $", il prossimo alle {nextCheck.Value:HH:mm}"
: "";
ScheduleText.Text = char.ToUpper(last[0]) + last[1..] + next + ".";
}
/// <summary>Ridisegna i contatori.</summary>
public void SetCounters(FreeBidsStats.Snapshot s)
{
SessionBidsText.Text = s.SessionBids.ToString();
TodayBidsText.Text = s.BidsToday.ToString();
TotalBidsText.Text = s.TotalBids.ToString();
TotalClaimedText.Text = s.TotalClaimed.ToString();
var lastClaim = s.LastClaimAt.HasValue
? $"ultimo riscatto il {s.LastClaimAt.Value:dd/MM/yyyy} alle {s.LastClaimAt.Value:HH:mm}"
: "nessun riscatto ancora andato a buon fine";
CountersDetailText.Text =
$"{s.SessionChecks} controlli in questa sessione · {s.TotalChecks} da sempre · " +
$"{s.SessionClaimed} ricompense prese in sessione · {lastClaim}.";
}
/// <summary>
/// Aggiunge una riga al registro, a video e su file.
///
/// <para>Il file serve perché questo pannello è l'unico posto in cui si vede cosa ha
/// fatto il riscatto automatico, e il riscatto lavora quando nessuno guarda: senza
/// registro su disco, tutto ciò che è successo mentre l'applicazione era chiusa —
/// o nelle duecento righe precedenti — sarebbe perso.</para>
/// </summary>
public void AppendActivity(string message, bool isProblem = false)
{
TextLogService.FreeBids(message, isProblem);
_activity.Insert(0, new ActivityEntry(DateTime.Now.ToString("HH:mm:ss"), message, isProblem));
while (_activity.Count > MaxActivityEntries)
_activity.RemoveAt(_activity.Count - 1);
ActivityEmptyText.Visibility = Visibility.Collapsed;
}
// ── Eventi dell'interfaccia ──────────────────────────────────────
private void RefreshButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RefreshBalanceClickedEvent, this));
private void CheckNowButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(CheckNowClickedEvent, this));
private void OpenExternalButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenExternalClickedEvent, this));
private void OpenInternalButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenInternalClickedEvent, this));
private void OpenVouchersButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenVouchersClickedEvent, this));
private void OpenBuyBidsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenBuyBidsClickedEvent, this));
private void ResetCountersButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ResetCountersClickedEvent, this));
private void AutoClaimCheckBox_Changed(object sender, RoutedEventArgs e)
{
if (_loading) return;
RaiseEvent(new RoutedEventArgs(OptionsChangedEvent, this));
}
private void CheckMinutesTextBox_LostFocus(object sender, RoutedEventArgs e)
{
if (_loading) return;
// Si riscrive il valore corretto: chi ha digitato "2" deve vedere che il
// minimo applicato è 5, invece di credere di aver impostato due minuti.
var minutes = CheckMinutes;
if (CheckMinutesTextBox.Text != minutes.ToString())
CheckMinutesTextBox.Text = minutes.ToString();
RaiseEvent(new RoutedEventArgs(OptionsChangedEvent, this));
}
private void HarvestButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(HarvestClickedEvent, this));
private void OpenSourceButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenPromoSourceClickedEvent, this));
private void ForgetPromosButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ForgetPromosClickedEvent, this));
private void ClaimPromoButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClaimPromoClickedEvent, this));
/// <summary>Invio nel campo vale come clic: si incolla e si preme invio.</summary>
private void PromoCodeTextBox_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Enter) return;
e.Handled = true;
RaiseEvent(new RoutedEventArgs(ClaimPromoClickedEvent, this));
}
private void OpenConfigButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenConfigClickedEvent, this));
private void ClearLogButton_Click(object sender, RoutedEventArgs e)
{
_activity.Clear();
ActivityEmptyText.Visibility = Visibility.Visible;
}
// ── Routed events ────────────────────────────────────────────────
public static readonly RoutedEvent RefreshBalanceClickedEvent = EventManager.RegisterRoutedEvent(
"RefreshBalanceClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent CheckNowClickedEvent = EventManager.RegisterRoutedEvent(
"CheckNowClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OpenExternalClickedEvent = EventManager.RegisterRoutedEvent(
"OpenExternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OpenInternalClickedEvent = EventManager.RegisterRoutedEvent(
"OpenInternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OpenVouchersClickedEvent = EventManager.RegisterRoutedEvent(
"OpenVouchersClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OpenBuyBidsClickedEvent = EventManager.RegisterRoutedEvent(
"OpenBuyBidsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent ResetCountersClickedEvent = EventManager.RegisterRoutedEvent(
"ResetCountersClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OptionsChangedEvent = EventManager.RegisterRoutedEvent(
"OptionsChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent ClaimPromoClickedEvent = EventManager.RegisterRoutedEvent(
"ClaimPromoClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent HarvestClickedEvent = EventManager.RegisterRoutedEvent(
"HarvestClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OpenPromoSourceClickedEvent = EventManager.RegisterRoutedEvent(
"OpenPromoSourceClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent ForgetPromosClickedEvent = EventManager.RegisterRoutedEvent(
"ForgetPromosClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public static readonly RoutedEvent OpenConfigClickedEvent = EventManager.RegisterRoutedEvent(
"OpenConfigClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(FreeBidsControl));
public event RoutedEventHandler RefreshBalanceClicked
{
add { AddHandler(RefreshBalanceClickedEvent, value); }
remove { RemoveHandler(RefreshBalanceClickedEvent, value); }
}
public event RoutedEventHandler CheckNowClicked
{
add { AddHandler(CheckNowClickedEvent, value); }
remove { RemoveHandler(CheckNowClickedEvent, value); }
}
public event RoutedEventHandler OpenExternalClicked
{
add { AddHandler(OpenExternalClickedEvent, value); }
remove { RemoveHandler(OpenExternalClickedEvent, value); }
}
public event RoutedEventHandler OpenInternalClicked
{
add { AddHandler(OpenInternalClickedEvent, value); }
remove { RemoveHandler(OpenInternalClickedEvent, value); }
}
public event RoutedEventHandler OpenVouchersClicked
{
add { AddHandler(OpenVouchersClickedEvent, value); }
remove { RemoveHandler(OpenVouchersClickedEvent, value); }
}
public event RoutedEventHandler OpenBuyBidsClicked
{
add { AddHandler(OpenBuyBidsClickedEvent, value); }
remove { RemoveHandler(OpenBuyBidsClickedEvent, value); }
}
public event RoutedEventHandler ResetCountersClicked
{
add { AddHandler(ResetCountersClickedEvent, value); }
remove { RemoveHandler(ResetCountersClickedEvent, value); }
}
public event RoutedEventHandler OptionsChanged
{
add { AddHandler(OptionsChangedEvent, value); }
remove { RemoveHandler(OptionsChangedEvent, value); }
}
public event RoutedEventHandler ClaimPromoClicked
{
add { AddHandler(ClaimPromoClickedEvent, value); }
remove { RemoveHandler(ClaimPromoClickedEvent, value); }
}
public event RoutedEventHandler HarvestClicked
{
add { AddHandler(HarvestClickedEvent, value); }
remove { RemoveHandler(HarvestClickedEvent, value); }
}
public event RoutedEventHandler OpenPromoSourceClicked
{
add { AddHandler(OpenPromoSourceClickedEvent, value); }
remove { RemoveHandler(OpenPromoSourceClickedEvent, value); }
}
public event RoutedEventHandler ForgetPromosClicked
{
add { AddHandler(ForgetPromosClickedEvent, value); }
remove { RemoveHandler(ForgetPromosClickedEvent, value); }
}
public event RoutedEventHandler OpenConfigClicked
{
add { AddHandler(OpenConfigClickedEvent, value); }
remove { RemoveHandler(OpenConfigClickedEvent, value); }
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<UserControl x:Class="AutoBidder.Controls.HelpHeader"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
x:Name="Root"
HorizontalAlignment="Left">
<!-- Titolo di sezione con la spiegazione a portata di puntatore.
Le descrizioni stavano stampate sotto ogni titolo: si leggono una volta e poi
restano lì a occupare metà pagina per sempre. Qui il testo c'è ancora — non è
stato buttato — ma si mostra passandoci sopra, come per tutti gli altri comandi
dell'applicazione. Il pallino accanto al titolo serve a far sapere che c'è. -->
<StackPanel Orientation="Horizontal"
Background="Transparent"
Margin="{Binding Margin, ElementName=Root}"
ToolTipService.InitialShowDelay="250"
ToolTipService.ShowDuration="60000">
<StackPanel.ToolTip>
<ToolTip Visibility="{Binding HelpVisibility, ElementName=Root}">
<TextBlock Text="{Binding Help, ElementName=Root}"
TextWrapping="Wrap"
MaxWidth="420"/>
</ToolTip>
</StackPanel.ToolTip>
<TextBlock Text="{Binding Title, ElementName=Root}"
Style="{StaticResource BlockTitle}"
VerticalAlignment="Center"/>
<TextBlock Text="&#xE946;"
FontFamily="Segoe MDL2 Assets"
FontSize="12"
Margin="6,2,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.TextFaint}"
Visibility="{Binding HelpVisibility, ElementName=Root}"/>
</StackPanel>
</UserControl>
+58
View File
@@ -0,0 +1,58 @@
using System.Windows;
using System.Windows.Controls;
namespace AutoBidder.Controls
{
/// <summary>
/// Titolo di sezione con la spiegazione nel suggerimento invece che stampata sotto.
///
/// <para>Serve a togliere dalla pagina i paragrafi descrittivi senza perderne il
/// contenuto: si leggono una volta e poi restano a occupare spazio a ogni apertura
/// delle Impostazioni. Passando il puntatore il testo torna, esattamente come per i
/// pulsanti del resto dell'applicazione.</para>
///
/// <para>Uso: <c>&lt;ctl:HelpHeader Title="Motore di Precisione" Help="…"/&gt;</c>.
/// Senza <see cref="Help"/> si comporta come un titolo semplice, senza pallino.</para>
/// </summary>
public partial class HelpHeader : UserControl
{
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register(nameof(Title), typeof(string), typeof(HelpHeader),
new PropertyMetadata(""));
public static readonly DependencyProperty HelpProperty =
DependencyProperty.Register(nameof(Help), typeof(string), typeof(HelpHeader),
new PropertyMetadata("", OnHelpChanged));
private static readonly DependencyPropertyKey HelpVisibilityKey =
DependencyProperty.RegisterReadOnly(nameof(HelpVisibility), typeof(Visibility), typeof(HelpHeader),
new PropertyMetadata(Visibility.Collapsed));
public static readonly DependencyProperty HelpVisibilityProperty = HelpVisibilityKey.DependencyProperty;
public HelpHeader() => InitializeComponent();
/// <summary>Il titolo della sezione.</summary>
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
/// <summary>La spiegazione, mostrata al passaggio del puntatore.</summary>
public string Help
{
get => (string)GetValue(HelpProperty);
set => SetValue(HelpProperty, value);
}
/// <summary>Il pallino compare solo quando c'è davvero qualcosa da leggere.</summary>
public Visibility HelpVisibility => (Visibility)GetValue(HelpVisibilityProperty);
private static void OnHelpChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var visible = !string.IsNullOrWhiteSpace(e.NewValue as string);
d.SetValue(HelpVisibilityKey, visible ? Visibility.Visible : Visibility.Collapsed);
}
}
}
+377
View File
@@ -0,0 +1,377 @@
<UserControl x:Class="AutoBidder.Controls.LearningControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="Stat" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Lg}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="StatLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Sm}"/>
<Setter Property="Margin" Value="0,2,0,0"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="RefreshButton" Style="{StaticResource IconButton}"
ToolTip="Aggiorna i numeri" Click="RefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Apprendimento" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
<Border Style="{StaticResource Pill}" ToolTip="Aste da cui il modello ha imparato">
<TextBlock x:Name="PillAuctions" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Text}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Esempi (puntate) che hanno aggiornato i pesi">
<TextBlock x:Name="PillUpdates" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Fattore di calibrazione: osservato/previsto sugli ultimi esempi. 1 = scala giusta">
<TextBlock x:Name="PillCalibration" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border x:Name="PillReadyBorder" Style="{StaticResource Pill}"
ToolTip="Pronto = ha appreso abbastanza aste per fermare una puntata. Prima parla soltanto.">
<TextBlock x:Name="PillReady" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Warning}"/>
</Border>
</StackPanel>
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="EvaluateButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Valuta ora.&#10;Addestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione»."
Click="EvaluateButton_Click">
<TextBlock x:Name="EvaluateGlyph" Style="{StaticResource Glyph}" Text="&#xE9F5;"/>
</Button>
<Button x:Name="ExportDecisionsButton" Style="{StaticResource IconButton}"
ToolTip="Esporta le decisioni in CSV.&#10;Tutte le decisioni del motore, dal vivo e in shadow, con esito e motivazione per esteso (reason_detail). Punto e virgola, pronto per un foglio di calcolo. Chiede dove salvare il file."
Click="ExportDecisionsButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE9F9;"/>
</Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<ToggleButton x:Name="OptionsButton" Style="{StaticResource IconToggle}"
ToolTip="Altre operazioni sull'apprendimento.&#10;Studiare le aste non ancora apprese, ricostruire il profilo, rifare il bandit, azzerare lo sfidante o la calibrazione. Ognuna mostra l'avanzamento e si può annullare.">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE9E9;"/>
</ToggleButton>
<Popup x:Name="OptionsPopup" PlacementTarget="{Binding ElementName=OptionsButton}" Placement="Bottom"
StaysOpen="False" AllowsTransparency="True" HorizontalOffset="-300" VerticalOffset="4"
IsOpen="{Binding IsChecked, ElementName=OptionsButton, Mode=TwoWay}">
<Border Background="{DynamicResource Brush.Surface}" BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1" CornerRadius="10" Padding="6" Width="340">
<StackPanel>
<TextBlock Style="{StaticResource Hint}" Margin="10,4,10,6" Text="Operazioni sull'apprendimento"/>
<Button Style="{StaticResource MenuEntry}" Content="Studia le aste chiuse non ancora apprese"
ToolTip="Lo studio automatico all'avvio si ferma dopo qualche minuto: questo va in fondo a tutte le aste chiuse del database che il modello non ha ancora visto. Non tocca ciò che è già appreso."
Click="LearnPending_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Ricostruisci il profilo per prodotto e fascia oraria"
ToolTip="Rifà da tutto lo storico le puntate tipiche del vincitore e il rapporto di chiusura per prodotto, fascia oraria e giorno. Modello e bandit non si toccano."
Click="RebuildProfile_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Rifai il bandit dalle decisioni registrate"
ToolTip="Butta il bandit Thompson e lo rifà da tutte le decisioni con esito registrate nel database: le stesse osservazioni che avrebbe raccolto asta per asta, senza doppioni."
Click="RebuildBandit_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Azzera lo sfidante (rete neurale)"
ToolTip="La rete riparte da zero e il confronto Brier ricomincia. Il campione logistico resta com'è: è lui che risponde al motore finché lo sfidante non lo batte."
Click="ResetChallenger_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Azzera la calibrazione del modello"
ToolTip="Il fattore osservato/previsto torna a 1 e si rimisura sugli esempi nuovi. Utile se il mercato è cambiato di colpo e la scala delle probabilità è rimasta indietro."
Click="ResetCalibration_Click"/>
<Border Height="1" Background="{DynamicResource Brush.Border}" Margin="6,6"/>
<Button Style="{StaticResource MenuEntry}" Content="Ricomincia da capo: azzera tutto e ristudia lo storico"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Butta modello, profilo, sfidante, bandit e contatore delle aste studiate, e ristudia tutte le aste chiuse del database. Non tocca lo storico né i prodotti. Chiede conferma."
Click="RetrainButton_Click"/>
</StackPanel>
</Border>
</Popup>
<Button x:Name="RetrainButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ricomincia da capo.&#10;Butta modello, profilo, sfidante, bandit e latenza appresa, e ristudia tutte le aste chiuse del database. Non tocca lo storico né i prodotti. Chiede conferma."
Click="RetrainButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE7A6;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ═══ Corpo ═══ -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<Grid Margin="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Stato -->
<Border Grid.Row="0" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Stato" Margin="0,0,0,10"
Help="Il modello impara da ogni asta che chiude: per ogni puntata, è rimasta senza risposta? È l'unica etichetta abbondante — milioni di esempi — mentre le vittorie proprie sono poche decine. Il comportamento avversario non dipende da chi ha puntato, e questo rende utilizzabili le puntate altrui.&#10;&#10;Decide col valore atteso: probabilità appresa × margine residuo costo della puntata. Sotto zero non punta, e lo scrive nel registro con i numeri. Decide solo dopo la soglia di aste apprese: prima parla soltanto."/>
<UniformGrid Columns="3" Rows="2">
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatAuctions" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="aste apprese"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatUpdates" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="esempi visti"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatProfile" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="aste nel profilo"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatCalibration" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="calibrazione (1 = giusta)"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatThreshold" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="soglia per decidere"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBootstrap" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="studio delle aste"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="StatDecisions" Style="{StaticResource Hint}" Margin="0,10,0,0" Text=""/>
</StackPanel>
</Border>
<!-- Cosa ha imparato -->
<Border Grid.Row="0" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Cosa ha imparato" Margin="0,0,0,10"
Help="I pesi del modello, dal più forte. Positivo = quella condizione rende più probabile che una puntata resti senza risposta; negativo = meno probabile. Le variabili sono a fasce e il modello è lineare proprio per poterli leggere così.&#10;&#10;Il prodotto è ridotto a 32 cassetti con una funzione di hash: due prodotti possono condividere un cassetto, ed è accettato."/>
<DataGrid x:Name="WeightsGrid" MaxHeight="260" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Condizione" Binding="{Binding Nome}" Width="*"/>
<DataGridTextColumn Header="Peso" Binding="{Binding PesoDisplay}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Effetto" Binding="{Binding Effetto}" Width="Auto" MinWidth="110"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Ultime decisioni -->
<Border Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Ultime decisioni" Margin="0,0,0,10"
Help="Ogni volta che il motore era pronto a puntare ha chiesto al modello. P è la probabilità che la puntata resti senza risposta; il valore atteso è P × margine residuo costo. «Fermata» = il cancello ha detto no. «Parere» = il modello non aveva ancora appreso abbastanza per decidere, e si è limitato a dirlo."/>
<DataGrid x:Name="DecisionsGrid" MaxHeight="280" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Ora" Binding="{Binding Ora}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Asta" Binding="{Binding Asta}" Width="*"/>
<DataGridTextColumn Header="Prezzo" Binding="{Binding Prezzo}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="P senza risposta" Binding="{Binding Prob}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="Valore atteso" Binding="{Binding Ev}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Esito" Binding="{Binding Esito}" Width="Auto" MinWidth="90"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Profilo per prodotto -->
<Border Grid.Row="2" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Profilo per prodotto e fascia oraria" Margin="0,0,0,10"
Help="Cosa aspettarsi prima che un'asta cominci: puntate del vincitore e prezzo di chiusura in percentuale del valore, per prodotto, fascia oraria e tipo di giorno. Solo le combinazioni con aste osservate; quando i dati sono pochi il motore restringe verso il prodotto e poi verso la media generale.&#10;&#10;Fasce: 0-8 notte e ore sospese · 9 · 10-12 · 13-17 · 18-20 · 21-23."/>
<DataGrid x:Name="ProfileGrid" MaxHeight="320" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Prodotto" Binding="{Binding Prodotto}" Width="*"/>
<DataGridTextColumn Header="Fascia" Binding="{Binding Fascia}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Giorno" Binding="{Binding Giorno}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Aste" Binding="{Binding Aste}" Width="Auto" MinWidth="50"/>
<DataGridTextColumn Header="Punt. vincitore" Binding="{Binding Puntate}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Chiusura" Binding="{Binding Chiusura}" Width="Auto" MinWidth="80"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Valutazione -->
<Border Grid.Row="2" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Ultima valutazione" Margin="0,0,0,10"
Help="Il rapporto dell'ultima valutazione sulle aste registrate in questo database. Le misure che contano: il sollevamento (quanto più spesso le puntate a probabilità alta erano davvero finali, rispetto al caso), la calibrazione per fasce (la probabilità prevista deve somigliare a quella osservata), e cosa il cancello avrebbe fatto sulle tue puntate vere."/>
<TextBox x:Name="EvaluationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="200" MaxHeight="320"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Nessuna valutazione ancora. Premi «Valuta ora»."/>
</StackPanel>
</Border>
<!-- Autonomia sul momento -->
<Border Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Autonomia sul momento" Margin="0,0,0,10"
Help="Due cose che il sistema decide da solo sulla sessione in corso, dentro i paletti delle impostazioni.&#10;&#10;ANTICIPO: margine di sicurezza più la coda alta (p99) della latenza misurata adesso. Una puntata arrivata tardi alza il margine di 150 ms subito; venti puntate in tempo lo abbassano di 25. Il risultato resta fra il minimo e il massimo impostati; un anticipo scritto a mano su una singola asta vince sempre.&#10;&#10;REGIME: per ogni asta seguita. Calmo = punta se il valore atteso è positivo. Sfogo = gli altri si stanno battendo, si aspetta che si sfoghino: si rientra solo dopo «pazienza» cicli buoni di fila. Sondaggio = una puntata di prova; se viene coperta entro 9 s si torna a Sfogo e la pazienza raddoppia (fino a 24)."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Anticipo adattivo" FontWeight="SemiBold" Margin="0,0,0,8"
Foreground="{DynamicResource Brush.Text}"/>
<UniformGrid Columns="3" Rows="2">
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLeadNow" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="anticipo adesso"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLatP99" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="coda del ping (p99)"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLatMargin" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="margine"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatP50" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="ping mediano"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatSamples" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="campioni"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatLate" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="puntate tardive / totali"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="StatLeadHint" Style="{StaticResource Hint}" Margin="0,8,0,0" Text=""/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Regime per asta" FontWeight="SemiBold" Margin="0,0,0,8"
Foreground="{DynamicResource Brush.Text}"/>
<DataGrid x:Name="RegimeGrid" MaxHeight="240" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Asta" Binding="{Binding Asta}" Width="*"/>
<DataGridTextColumn Header="Stato" Binding="{Binding StatoAsta}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Regime" Binding="{Binding Regime}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Pazienza" Binding="{Binding Pazienza}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Sondaggi coperti" Binding="{Binding Sondaggi}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="P senza risposta" Binding="{Binding Prob}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="Valore atteso" Binding="{Binding Ev}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Duello" Binding="{Binding Duello}" Width="Auto" MinWidth="60"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock x:Name="RegimeHint" Style="{StaticResource Hint}" Margin="0,8,0,0" Text=""/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- Shadow: cosa avrebbe reso -->
<Border Grid.Row="4" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Shadow: cosa avrebbero reso le decisioni" Margin="0,0,0,10"
Help="Ogni decisione registrata (in Osserva come in Attiva) riceve un esito alla chiusura dell'asta: se il prezzo non è più salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si giudicano sugli stessi istanti la policy in uso (modello + regime), il bandit Thompson che gira in shadow, «punta sempre» e «non punta mai».&#10;&#10;ROI per puntata = netto / puntate. L'intervallo di confidenza dice se il null-model (ROI = 0) è battuto davvero o per caso. Chi risponde al motore fra modello logistico e rete neurale lo decide il punteggio di Brier prequenziale qui sotto."/>
<UniformGrid Columns="3" Rows="1" Margin="0,0,0,8">
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatChampion" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="chi risponde al motore"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBrier" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="Brier logistico / rete"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBandit" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="bandit: contesti / osservazioni"/>
</StackPanel>
</UniformGrid>
<TextBox x:Name="ShadowBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="180" MaxHeight="300"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Nessuna decisione con esito ancora."/>
</StackPanel>
</Border>
<!-- Simulatore -->
<Border Grid.Row="4" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Simulatore: le policy a confronto" Margin="0,0,0,10"
Help="Aste sintetiche con avversari tarati sul database (puntatori per asta, profondità dei cicli, quota di autopuntate, chiusura mediana): aggressivi, cecchini, autopuntate a 2 s, principianti, ognuno con il costo affondato degli umani. Le policy giocano le stesse aste (stessi semi): non puntare mai, puntare sempre, la regola del valore atteso, il bandit che impara giocando, e l'agente Q addestrato qui dentro con penalità conservativa.&#10;&#10;Il simulatore non è Bidoo: i numeri assoluti non contano, il confronto sì. Ed è l'unico posto dove si vede come reagirebbero gli altri a una nostra puntata."/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button x:Name="SimulateButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Simula 500 aste per policy" Margin="0,0,8,0"
Click="SimulateButton_Click"/>
<TextBlock x:Name="SimulateHint" Style="{StaticResource Hint}" VerticalAlignment="Center" Text=""/>
</StackPanel>
<TextBox x:Name="SimulationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="180" MaxHeight="300"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Premi «Simula» per mettere le policy a confronto."/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
</UserControl>
+445
View File
@@ -0,0 +1,445 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using AutoBidder.Ml;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// La scheda dell'apprendimento: cosa sa il modello, cosa ha deciso di recente, cosa
/// aspettarsi per prodotto e ora, e l'ultima valutazione.
///
/// <para>Legge direttamente da <see cref="LearningService"/>: non ha bisogno della
/// finestra principale per niente, e si aggiorna da sola ogni pochi secondi mentre è
/// visibile. Tutto il resto dell'applicazione non sa che esiste.</para>
/// </summary>
public partial class LearningControl : UserControl
{
private readonly DispatcherTimer _refresh;
private CancellationTokenSource? _evaluation;
private sealed class PesoRow
{
public string Nome { get; init; } = "";
public double Peso { get; init; }
public string PesoDisplay => Peso.ToString("+0.000;-0.000");
public string Effetto => Math.Abs(Peso) < 0.05 ? "quasi nullo"
: Peso > 0 ? "più senza risposta" : "più risposte";
}
private sealed class ProfiloRow
{
public string Prodotto { get; init; } = "";
public string Fascia { get; init; } = "";
public string Giorno { get; init; } = "";
public long Aste { get; init; }
public string Puntate { get; init; } = "";
public string Chiusura { get; init; } = "";
}
private sealed class RegimeRow
{
public string Asta { get; init; } = "";
public string StatoAsta { get; init; } = "";
public string Regime { get; init; } = "";
public string Pazienza { get; init; } = "";
public string Sondaggi { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Duello { get; init; } = "";
}
/// <summary>
/// Da dove prendere le aste seguite adesso, per la tabella dei regimi. Lo imposta
/// la finestra principale: la scheda non conosce il monitor, e non deve.
/// </summary>
public Func<IReadOnlyList<Models.AuctionInfo>>? AuctionsProvider { get; set; }
private sealed class DecisioneRow
{
public string Ora { get; init; } = "";
public string Asta { get; init; } = "";
public string Prezzo { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Esito { get; init; } = "";
}
public LearningControl()
{
InitializeComponent();
_refresh = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_refresh.Tick += (_, _) => { if (IsVisible) Refresh(); };
IsVisibleChanged += (_, e) =>
{
if ((bool)e.NewValue) { Refresh(); _refresh.Start(); }
else _refresh.Stop();
};
}
/// <summary>Rilegge tutto dal servizio. Costa poco: sono numeri già in memoria.</summary>
public void Refresh()
{
try
{
var settings = SettingsManager.Load();
var apprese = LearningService.AuctionsLearned;
var soglia = Math.Max(1, settings.LearningMinAuctions);
var pronto = LearningService.IsReady(settings);
var calib = LearningService.CalibrationFactor;
PillAuctions.Text = $"{apprese:N0} aste";
PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi";
PillCalibration.Text = $"calibrazione {calib:N2}";
// L'interruttore in barra: spento, il modello studia ma non decide.
PillReady.Text = !settings.LearningEnabled ? "SPENTO (interruttore in barra)"
: pronto ? "pronto" : $"in ascolto ({apprese}/{soglia})";
PillReady.Foreground = (System.Windows.Media.Brush)FindResource(
!settings.LearningEnabled ? "Brush.Danger" : pronto ? "Brush.Success" : "Brush.Warning");
StatAuctions.Text = apprese.ToString("N0");
StatUpdates.Text = LearningService.ModelUpdates.ToString("N0");
StatProfile.Text = LearningService.ProfileAuctions.ToString("N0");
StatCalibration.Text = calib.ToString("N2");
StatThreshold.Text = soglia.ToString("N0");
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
RefreshAutonomy(settings);
RefreshShadow(settings);
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
StatDecisions.Text = decisioni.Count == 0
? "Nessuna decisione ancora: il modello risponde quando il motore è pronto a puntare."
: $"Ultime {decisioni.Count} decisioni: {fermate} fermate, {decisioni.Count - fermate} lasciate passare" +
(settings.LearningGateEnabled ? "." : ". Il cancello è spento nelle impostazioni: il modello parla ma non decide.");
WeightsGrid.ItemsSource = LearningService.Pesi()
.Take(30)
.Select(p => new PesoRow { Nome = p.Nome, Peso = p.Peso })
.ToList();
DecisionsGrid.ItemsSource = decisioni
.Select(d => new DecisioneRow
{
Ora = d.At.ToString("HH:mm:ss"),
Asta = d.Auction,
Prezzo = d.Price.ToString("F2") + " €",
Prob = d.Probability.ToString("P2"),
Ev = d.ExpectedValue.ToString("+0.000;-0.000") + " €",
Esito = !d.Ready ? "parere" : d.Blocked ? "fermata" : "passa"
})
.ToList();
ProfileGrid.ItemsSource = LearningService.ProfileRows(150)
.Select(r => new ProfiloRow
{
Prodotto = r.Product,
Fascia = FasciaLabel(r.HourBand),
Giorno = r.Weekend ? "festivo" : "feriale",
Aste = r.Auctions,
Puntate = double.IsNaN(r.WinnerBids) ? "—" : r.WinnerBids.ToString("N0"),
Chiusura = double.IsNaN(r.CloseRatio) ? "—" : r.CloseRatio.ToString("P1")
})
.ToList();
if (_evaluation == null)
{
var ultima = LearningService.LastEvaluation();
if (!string.IsNullOrWhiteSpace(ultima)) EvaluationBox.Text = ultima;
}
}
catch (Exception ex)
{
StatDecisions.Text = $"Aggiornamento non riuscito: {ex.Message}";
}
}
private static string FasciaLabel(int band) => band switch
{
0 => "0-8", 1 => "9", 2 => "10-12", 3 => "13-17", 4 => "18-20", _ => "21-23"
};
/// <summary>La sezione «Autonomia sul momento»: modello di latenza e regime per asta.</summary>
private void RefreshAutonomy(AppSettings settings)
{
var lat = LatencyModel.Snapshot();
var lead = LatencyModel.RecommendedLeadMs(settings);
StatLeadNow.Text = $"{lead} ms";
StatLatP99.Text = lat.Samples > 0 ? $"{lat.P99} ms" : "—";
StatLatP50.Text = lat.Samples > 0 ? $"{lat.P50} ms" : "—";
StatLatMargin.Text = $"{lat.MarginMs} ms";
StatLatSamples.Text = lat.Samples.ToString("N0");
StatLatLate.Text = $"{lat.LateBids} / {lat.Bids}";
StatLeadHint.Text = !settings.AdaptiveLeadEnabled
? $"Anticipo adattivo spento nelle impostazioni: vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: lat.Samples < 10
? $"Ancora pochi campioni: finché non ce ne sono dieci vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: $"Margine {lat.MarginMs} + coda {lat.P99} = {lat.MarginMs + lat.P99} ms, tenuto fra {settings.LeadMinMs} e {settings.LeadMaxMs}.";
var aste = AuctionsProvider?.Invoke() ?? Array.Empty<Models.AuctionInfo>();
var righe = aste
.Where(a => a.State != Models.RunState.Stopped)
.Select(a => new RegimeRow
{
Asta = a.Name,
StatoAsta = a.State == Models.RunState.Active ? "Attiva" : "Osserva",
Regime = a.Regime.StatoAttuale switch
{
CompetitionRegime.Stato.Sfogo => $"Sfogo ({a.Regime.CicliBuoni}/{a.Regime.Pazienza})",
CompetitionRegime.Stato.Sondaggio => "Sondaggio",
_ => "Calmo"
},
Pazienza = a.Regime.Pazienza.ToString(),
Sondaggi = a.Regime.SondaggiFalliti.ToString(),
Prob = a.LearnedUnansweredProbability is { } p ? p.ToString("P1") : "—",
Ev = a.LearnedExpectedValue is { } ev ? ev.ToString("+0.000;-0.000") + " €" : "—",
Duello = a.AutoBidDuelDetected ? "sì" : a.AutoResponsesInARow > 0 ? $"{a.AutoResponsesInARow}/5" : ""
})
.ToList();
RegimeGrid.ItemsSource = righe;
var sfogo = righe.Count(r => r.Regime.StartsWith("Sfogo"));
RegimeHint.Text = righe.Count == 0
? "Nessuna asta seguita in questo momento."
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
}
private DateTime _shadowRefreshedAt = DateTime.MinValue;
/// <summary>Campione, Brier, bandit e il rapporto shadow (ogni mezzo minuto: interroga il database).</summary>
private void RefreshShadow(AppSettings settings)
{
var (bl, bn, samples) = LearningService.BrierScores;
StatChampion.Text = LearningService.ChallengerLeads ? "rete neurale" : "logistico";
StatBrier.Text = samples == 0 ? "—" : $"{bl:F5} / {bn:F5}";
StatBandit.Text = $"{LearningService.Bandit.Arms:N0} / {LearningService.Bandit.Observations:N0}";
if ((DateTime.Now - _shadowRefreshedAt).TotalSeconds < 30) return;
_shadowRefreshedAt = DateTime.Now;
try
{
var report = ShadowReport.Compute(Data.AuctionDatabase.Instance, settings, 30);
ShadowBox.Text = report.WithOutcome == 0
? $"Decisioni registrate negli ultimi 30 giorni: {report.TotalDecisions:N0}, nessuna con esito ancora (arriva alla chiusura dell'asta)."
: report.Text;
}
catch (Exception ex)
{
ShadowBox.Text = $"Rapporto non disponibile: {ex.Message}";
}
}
private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
{
string? path;
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "Esporta le decisioni in CSV",
FileName = $"decisioni-{DateTime.Now:yyyyMMdd-HHmm}.csv",
Filter = "CSV (foglio di calcolo)|*.csv",
OverwritePrompt = true,
AddExtension = true
};
path = dialog.ShowDialog() == true ? dialog.FileName : null;
}
catch { path = null; }
if (path == null) return;
var result = DecisionExporter.ExportCsv(path);
if (result.Success)
{
try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
MessageBox.Show($"{result.Message}\n{result.Path}", "Decisioni", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
MessageBox.Show(result.Message, "Decisioni", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private CancellationTokenSource? _simulation;
private async void SimulateButton_Click(object sender, RoutedEventArgs e)
{
if (_simulation != null) { _simulation.Cancel(); return; }
_simulation = new CancellationTokenSource();
var ct = _simulation.Token;
SimulateButton.Content = "Annulla";
SimulationBox.Text = "Simulazione in corso…";
var settings = SettingsManager.Load();
try
{
var text = await Task.Run(() => SimulationLab.Run(Data.AuctionDatabase.Instance, settings, 500,
msg => Dispatcher.BeginInvoke(() => SimulateHint.Text = msg), ct), ct);
SimulationBox.Text = text;
SimulateHint.Text = $"fatto alle {DateTime.Now:HH:mm}";
}
catch (OperationCanceledException)
{
SimulationBox.Text = "Simulazione annullata.";
SimulateHint.Text = "";
}
catch (Exception ex)
{
SimulationBox.Text = $"Simulazione non riuscita: {ex.Message}";
SimulateHint.Text = "";
}
finally
{
_simulation = null;
SimulateButton.Content = "Simula 500 aste per policy";
}
}
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private async void EvaluateButton_Click(object sender, RoutedEventArgs e)
{
if (_evaluation != null)
{
_evaluation.Cancel();
return;
}
_evaluation = new CancellationTokenSource();
// Il pulsante è solo icona: mentre gira diventa una X, e il tooltip lo dice.
EvaluateGlyph.Text = "\uE711";
EvaluateButton.ToolTip = "Annulla la valutazione in corso.";
EvaluationBox.Text = "Valutazione in corso: lettura delle aste…";
try
{
// Al massimo tremila aste, le più recenti: bastano a giudicare, e la
// lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve.
var report = await LearningService.EvaluateAsync(3000,
msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg),
_evaluation.Token);
EvaluationBox.Text = report.Text;
}
catch (OperationCanceledException)
{
EvaluationBox.Text = "Valutazione annullata.";
}
catch (Exception ex)
{
EvaluationBox.Text = $"Valutazione non riuscita: {ex.Message}";
}
finally
{
_evaluation = null;
EvaluateGlyph.Text = "\uE9F5";
EvaluateButton.ToolTip = "Valuta ora.\nAddestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione».";
}
}
// ── Operazioni sull'apprendimento: tutte con la finestra di avanzamento ──
private Window? Host => Window.GetWindow(this);
private void CloseOptions() => OptionsButton.IsChecked = false;
/// <summary>Dice com'è finita un'operazione: errore, annullata, o il testo di riepilogo.</summary>
private void Conclude<T>(ProgressOutcome<T> esito, string titolo, Func<T, string> riepilogo)
{
Refresh();
if (esito.Error != null)
MessageBox.Show(Host!, esito.Error.Message, titolo, MessageBoxButton.OK, MessageBoxImage.Warning);
else if (esito.Cancelled)
MessageBox.Show(Host!, "Operazione annullata. Quello che era già fatto resta fatto.", titolo, MessageBoxButton.OK, MessageBoxImage.Information);
else
MessageBox.Show(Host!, riepilogo(esito.Result!), titolo, MessageBoxButton.OK, MessageBoxImage.Information);
}
private async void LearnPending_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Studio delle aste non ancora apprese",
"Cerco le aste chiuse che il modello non ha ancora visto…",
(p, ct) => Task.Run(() => LearningService.LearnPendingAsync(p, ct), ct), modal: false);
Conclude(esito, "Apprendimento", r => $"Studiate {r.Done:N0} aste, {r.Usable:N0} utilizzabili per il modello.");
}
private async void RebuildProfile_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Profilo per prodotto e fascia oraria",
"Rileggo lo storico…",
(p, ct) => Task.Run(() => LearningService.RebuildProfileFromHistory(p, ct), ct));
Conclude(esito, "Profilo", n => $"Profilo ricostruito da {n:N0} aste dello storico.");
}
private async void RebuildBandit_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Bandit dalle decisioni registrate",
"Leggo le decisioni con esito…",
(p, ct) => LearningService.RebuildBanditFromDecisionsAsync(p, ct));
Conclude(esito, "Bandit", r => $"Bandit rifatto da {r.Auctions:N0} aste: {r.Observations:N0} osservazioni.");
}
private void ResetChallenger_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var answer = MessageBox.Show(Host!, "Azzero lo sfidante (la rete) e il confronto Brier? Il campione logistico resta.",
"Sfidante", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
LearningService.ResetChallenger();
Refresh();
}
private void ResetCalibration_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var answer = MessageBox.Show(Host!, "Azzero la calibrazione? Il fattore torna a 1 e si rimisura sugli esempi nuovi.",
"Calibrazione", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
LearningService.ResetCalibration();
Refresh();
}
private async void RetrainButton_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var answer = MessageBox.Show(Host!,
"Butto modello, profilo, sfidante, bandit e contatore delle aste studiate, e ricomincio a studiare tutte le aste chiuse del database.\n\n" +
"Lo storico e i prodotti non vengono toccati. Puoi continuare a usare l'applicazione; la finestra mostra a che punto è.\n\nProcedo?",
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
RetrainButton.IsEnabled = false;
try
{
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Ricomincio da capo",
"Azzero modello, profilo, sfidante e bandit…",
(p, ct) => Task.Run(() => LearningService.RetrainFromScratchAsync(p, ct), ct), modal: false);
Conclude(esito, "Ricomincia da capo", r => $"Ristudiate {r.Done:N0} aste, {r.Usable:N0} utilizzabili per il modello.");
}
finally
{
RetrainButton.IsEnabled = true;
}
}
}
}
+468
View File
@@ -0,0 +1,468 @@
<UserControl x:Class="AutoBidder.Controls.ProductsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:vm="clr-namespace:AutoBidder.ViewModels"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<!-- ═══ Modificabile o no: deve vedersi a colpo d'occhio ═══
Le celle che si possono scrivere hanno il riquadro di una casella di testo;
le statistiche restano testo semplice e smorzato. Senza questa differenza la
griglia sembra tutta uguale e non si capisce dove si può intervenire. -->
<Style x:Key="EditableCell" TargetType="DataGridCell">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="VerticalAlignment" Value="Stretch"/>
<Setter Property="Cursor" Value="IBeam"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border x:Name="box"
Background="{DynamicResource Brush.Bg}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="5"
Margin="3,3"
Padding="6,0">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="box" Property="BorderBrush"
Value="{DynamicResource Brush.BorderStrong}"/>
</Trigger>
<Trigger Property="IsEditing" Value="True">
<Setter TargetName="box" Property="BorderBrush"
Value="{DynamicResource Brush.Accent}"/>
<Setter TargetName="box" Property="Background"
Value="{DynamicResource Brush.Surface}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Il pennino segnala nell'intestazione le colonne che accettano scrittura -->
<DataTemplate x:Key="EditableHeader">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding}" VerticalAlignment="Center"/>
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE70F;" FontSize="9"
Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Accent}"/>
</StackPanel>
</DataTemplate>
<!-- Numeri modificabili: allineati a destra, colore pieno -->
<Style x:Key="EditableText" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
</Style>
<!-- Statistiche: sola lettura, quindi smorzate -->
<Style x:Key="StatText" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
</Style>
<!-- Statistiche estese: il numero da solo non basta, il dettaglio (quartili,
campione verificato, frequenza) sta nel suggerimento. -->
<Style x:Key="StatsText" TargetType="TextBlock" BasedOn="{StaticResource StatText}">
<Setter Property="ToolTip" Value="{Binding StatsExplanation}"/>
</Style>
<!-- Valore, costo reale e risparmio: il ragionamento sta nel suggerimento.
In rosso quando vincere costa piu' che comprare: e' il caso che va visto
subito, non nascosto in mezzo agli altri numeri. -->
<Style x:Key="ValueText" TargetType="TextBlock" BasedOn="{StaticResource StatText}">
<Setter Property="ToolTip" Value="{Binding ValueExplanation}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasSavings}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Success}"/>
</DataTrigger>
<DataTrigger Binding="{Binding SavingsIsNegative}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Danger}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- Limiti consigliati: il numero da solo non basta, il ragionamento sta nel
suggerimento. In arancione quando il tetto non copre il prezzo tipico:
è il caso in cui l'articolo, con quelle puntate, non conviene. -->
<Style x:Key="AdviceText" TargetType="TextBlock" BasedOn="{StaticResource StatText}">
<Setter Property="ToolTip" Value="{Binding AdviceExplanation}"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.Info}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasAdvice}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextFaint}"/>
</DataTrigger>
<DataTrigger Binding="{Binding AdviceWarns}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="RefreshButton" Style="{StaticResource IconButton}"
ToolTip="Ricarica elenco e statistiche" Click="RefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Prodotti" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Pill}" ToolTip="Prodotti in elenco">
<TextBlock x:Name="ProductsCountText" Text="0 prodotti"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Prodotti con la stellina accesa">
<TextBlock x:Name="ProductsWatchedText" Text="0 seguiti"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Gold}"/>
</Border>
</StackPanel>
<!-- Ricerca -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"
Margin="14,0,10,0">
<Border Background="{DynamicResource Brush.Bg}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="8,0" Margin="0,0,12,0"
VerticalAlignment="Center">
<Grid Width="230">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE721;" FontSize="12"
Foreground="{DynamicResource Brush.TextFaint}" Margin="0,0,6,0"/>
<TextBox x:Name="SearchBox" Width="196" BorderThickness="0"
Background="Transparent" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"
TextChanged="SearchBox_TextChanged"
ToolTip="Filtra i prodotti per nome mentre scrivi"/>
</StackPanel>
<!-- Testo guida: sparisce appena si scrive qualcosa -->
<TextBlock x:Name="SearchPlaceholder" Text="Cerca un prodotto…"
IsHitTestVisible="False" Margin="20,0,0,0"
VerticalAlignment="Center"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"/>
</Grid>
</Border>
</StackPanel>
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ScanNowButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Cerca aste ora.&#10;Interroga subito il catalogo di Bidoo per i prodotti con la stellina accesa e mette nel monitor le aste nuove che trova, con i limiti del prodotto. Senza aspettare il prossimo giro automatico."
Click="ScanNowButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE721;"/>
</Button>
<Button x:Name="ReapplyButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Riapplica i limiti.&#10;Riscrive prezzo minimo e massimo, puntate massime, avvio e valore reale del prodotto selezionato sulle aste di quel prodotto già presenti nel monitor. Serve dopo aver cambiato i limiti in questa scheda."
Click="ReapplyButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE895;"/>
</Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button x:Name="RecalculateButton" Style="{StaticResource IconButton}"
ToolTip="Ricalcola dallo storico.&#10;Rilegge tutte le aste concluse dello storico e rifà da capo i numeri di ogni prodotto in elenco: aste viste, prezzo minimo, massimo e medio, puntate del vincitore, vittorie, osservazioni. I limiti scritti a mano e la stellina non cambiano; cambiano i numeri, e quindi i limiti consigliati. Mostra l'avanzamento e si può annullare."
Click="RecalculateButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE81C;"/>
</Button>
<Button x:Name="ApplySuggestedButton" Style="{StaticResource IconButton}"
ToolTip="Usa i limiti consigliati sul prodotto selezionato.&#10;Scrive prezzo minimo e massimo consigliati dallo storico: il massimo copre l'85% delle chiusure passate (regolabile in Impostazioni), abbassato se il budget non lo regge — valore del prodotto meno il costo delle puntate massime. Il minimo è il decimo percentile: sotto quel prezzo l'asta non si chiude quasi mai."
Click="ApplySuggestedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8EF;"/>
</Button>
<Button x:Name="ApplyAllSuggestedButton" Style="{StaticResource IconButton}"
ToolTip="Usa i limiti consigliati su tutti i prodotti.&#10;Scrive i limiti consigliati su ogni prodotto che ha abbastanza aste concluse per ricavarli. Chiede conferma e dice prima quante righe cambierebbe."
Click="ApplyAllSuggestedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE762;"/>
</Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button x:Name="RemoveButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Togli il prodotto selezionato.&#10;Lo elimina dall'elenco con i suoi limiti. Le aste già nel monitor restano dove sono; le statistiche del prodotto restano."
Click="RemoveButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE738;"/>
</Button>
<Button x:Name="UnwatchAllButton" Style="{StaticResource IconButton}"
ToolTip="Spegni tutte le stelline.&#10;Nessun prodotto viene più cercato in automatico. I prodotti restano in elenco con i loro limiti: si riaccendono una a una."
Click="UnwatchAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE734;"/>
</Button>
<Button x:Name="RemoveUnwatchedButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Togli i prodotti senza stellina.&#10;Elimina dall'elenco tutti i prodotti non seguiti, con i loro limiti. Restano solo quelli con la stellina accesa. Le statistiche per prodotto restano."
Click="RemoveUnwatchedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8D9;"/>
</Button>
<Button x:Name="ClearButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota l'elenco.&#10;Toglie tutti i prodotti e i loro limiti. Le statistiche per prodotto (aste concluse, prezzi tipici, consigli) restano: se il prodotto ricompare, i consigli ci sono ancora."
Click="ClearButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;"/>
</Button>
<Button x:Name="ClearAllButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Pulizia completa.&#10;Toglie tutti i prodotti, i loro limiti E le statistiche per prodotto. Lo storico delle aste non viene toccato: le statistiche si possono ricostruire da lì con «Pulisci dati incompleti» nello Storico."
Click="ClearAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE894;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ═══ Elenco prodotti: impostazioni e statistiche sulla stessa riga ═══ -->
<Grid Grid.Row="1" Margin="12">
<Border Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12">
<DataGrid x:Name="ProductsGrid" AutoGenerateColumns="False"
Background="Transparent" Margin="4"
CanUserAddRows="False" CanUserDeleteRows="False"
SelectionMode="Single" HeadersVisibility="Column"
RowHeight="32"
HorizontalScrollBarVisibility="Auto"
CellEditEnding="ProductsGrid_CellEditEnding"
SelectionChanged="ProductsGrid_SelectionChanged">
<DataGrid.Columns>
<!-- Stellina: acceso = le aste nuove entrano da sole -->
<DataGridTemplateColumn Header="" Width="Auto" MinWidth="38" IsReadOnly="True"
CanUserSort="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource MiniIconButton}"
ToolTip="{Binding WatchTooltip}"
Command="{Binding DataContext.ProductWatchCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="{Binding WatchGlyph}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextFaint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsWatched}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Gold}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Auto, non *: con venti colonne accanto lo spazio residuo non basta mai
e i nomi lunghi restano tagliati. Cosi' la colonna prende la
larghezza del nome piu' lungo e la griglia scorre in orizzontale. -->
<DataGridTextColumn Header="Prodotto" Binding="{Binding DisplayName}"
Width="Auto" MinWidth="220" IsReadOnly="True"/>
<!-- ── Limiti propri del prodotto: modificabili ── -->
<DataGridTextColumn Header="Min €" Binding="{Binding MinPriceText}"
Width="Auto" MinWidth="74"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Max €" Binding="{Binding MaxPriceText}"
Width="Auto" MinWidth="74"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Puntate" Binding="{Binding MaxClicksText}"
Width="Auto" MinWidth="78"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Valore reale €" Binding="{Binding MarketValueText}"
Width="Auto" MinWidth="96"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Entro min" Binding="{Binding MaxStartMinutesText}"
Width="Auto" MinWidth="86"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<!-- Le colonne non stanno nell'albero visuale: un Binding qui non
erediterebbe alcun DataContext, quindi il valore è statico. -->
<DataGridComboBoxColumn Header="Stato" Width="Auto" MinWidth="112"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
SelectedItemBinding="{Binding StateChoice}"
ItemsSource="{x:Static vm:ProductViewModel.StateChoices}"/>
<!-- ── Statistiche dello storico: sola lettura ── -->
<DataGridTextColumn Header="Aste" Binding="{Binding StatCountDisplay}"
Width="Auto" MinWidth="52" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<DataGridTextColumn Header="Vinte" Binding="{Binding StatWonCount}"
Width="Auto" MinWidth="56" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<DataGridTextColumn Header="%" Binding="{Binding StatWinRateDisplay}"
Width="Auto" MinWidth="52" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<DataGridTextColumn Header="Media" Binding="{Binding StatAverageDisplay}"
Width="Auto" MinWidth="78" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<DataGridTextColumn Header="Min" Binding="{Binding StatMinDisplay}"
Width="Auto" MinWidth="72" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<DataGridTextColumn Header="Max" Binding="{Binding StatMaxDisplay}"
Width="Auto" MinWidth="72" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<!-- ── Quanto vale, quanto costa vincerlo davvero ── -->
<DataGridTextColumn Header="Valore" Binding="{Binding StatValueDisplay}"
Width="Auto" MinWidth="80" IsReadOnly="True"
ElementStyle="{StaticResource ValueText}"/>
<DataGridTextColumn Header="Punt. vinc." Binding="{Binding StatWinnerBidsDisplay}"
Width="Auto" MinWidth="88" IsReadOnly="True"
ElementStyle="{StaticResource ValueText}"/>
<DataGridTextColumn Header="Costo reale" Binding="{Binding StatWinCostDisplay}"
Width="Auto" MinWidth="90" IsReadOnly="True"
ElementStyle="{StaticResource ValueText}"/>
<DataGridTextColumn Header="Risparmio" Binding="{Binding StatSavingsDisplay}"
Width="Auto" MinWidth="84" IsReadOnly="True"
ElementStyle="{StaticResource ValueText}"/>
<!-- ── La forbice, non solo il valore tipico ──
Su un articolo dove si vince con 12 puntate o con 900, la sola
mediana porta a impostare limiti che non reggono. -->
<DataGridTextColumn Header="Punt. min" Binding="{Binding StatMinWinnerBidsDisplay}"
Width="Auto" MinWidth="78" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="Punt. 75°" Binding="{Binding StatP75WinnerBidsDisplay}"
Width="Auto" MinWidth="78" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="Punt. max" Binding="{Binding StatMaxWinnerBidsDisplay}"
Width="Auto" MinWidth="80" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="Risp. min" Binding="{Binding StatMinSavingsDisplay}"
Width="Auto" MinWidth="80" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="Risp. max" Binding="{Binding StatMaxSavingsDisplay}"
Width="Auto" MinWidth="80" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="In utile" Binding="{Binding StatProfitableShareDisplay}"
Width="Auto" MinWidth="72" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="Ogni" Binding="{Binding StatFrequencyDisplay}"
Width="Auto" MinWidth="70" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<DataGridTextColumn Header="Verificate" Binding="{Binding StatWinnerBidsSampleDisplay}"
Width="Auto" MinWidth="82" IsReadOnly="True"
ElementStyle="{StaticResource StatsText}"/>
<!-- ── Limiti consigliati: sola lettura, più il bottone che li applica ── -->
<DataGridTextColumn Header="Min cons." Binding="{Binding SuggestedMinDisplay}"
Width="Auto" MinWidth="84" IsReadOnly="True"
ElementStyle="{StaticResource AdviceText}"/>
<DataGridTextColumn Header="Max cons." Binding="{Binding SuggestedMaxDisplay}"
Width="Auto" MinWidth="84" IsReadOnly="True"
ElementStyle="{StaticResource AdviceText}"/>
<DataGridTextColumn Header="Punt. cons." Binding="{Binding SuggestedMaxBidsDisplay}"
Width="Auto" MinWidth="88" IsReadOnly="True"
ElementStyle="{StaticResource AdviceText}"/>
<DataGridTemplateColumn Header="" Width="Auto" MinWidth="40" IsReadOnly="True" CanUserSort="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<!-- Applica i consigliati a questa riga soltanto. Il segno di
spunta si accende quando i limiti scritti sono già quelli
consigliati: serve a vedere a colpo d'occhio quali righe
resta da sistemare. -->
<Button Style="{StaticResource MiniIconButton}"
ToolTip="{Binding AdviceExplanation}"
Command="{Binding DataContext.ProductApplyAdviceCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE73E;">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextFaint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasAdvice}" Value="False">
<Setter Property="Opacity" Value="0.25"/>
</DataTrigger>
<DataTrigger Binding="{Binding MatchesAdvice}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Success}"/>
</DataTrigger>
<DataTrigger Binding="{Binding AdviceWarns}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Warning}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Punt./asta" Binding="{Binding StatAverageBidsDisplay}"
Width="Auto" MinWidth="82" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
<DataGridTextColumn Header="Aggiunte" Binding="{Binding AutoAddedCountDisplay}"
Width="Auto" MinWidth="74" IsReadOnly="True"
ElementStyle="{StaticResource StatText}"/>
</DataGrid.Columns>
</DataGrid>
</Border>
<!-- Stato vuoto -->
<StackPanel x:Name="ProductsEmptyState" VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="Collapsed">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE734;" FontSize="34"
Foreground="{DynamicResource Brush.TextFaint}"/>
<TextBlock Text="Nessun prodotto in elenco."
Margin="0,10,0,0" HorizontalAlignment="Center"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBlock Text="Aggiungili dalla scheda Cerca: la stellina lo segue, l'ingranaggio lo mette qui per dargli dei limiti."
Margin="0,4,0,0" HorizontalAlignment="Center" TextWrapping="Wrap" MaxWidth="420"
TextAlignment="Center"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>
+275
View File
@@ -0,0 +1,275 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.ViewModels;
namespace AutoBidder.Controls
{
/// <summary>
/// Scheda Prodotti: una riga per articolo, con la stellina, i limiti su misura e le
/// statistiche dello storico tutte sulla stessa riga.
///
/// <para>Il controllo non conosce né gli archivi né il monitor: espone eventi e lascia
/// decidere a MainWindow, com'è per gli altri pannelli.</para>
/// </summary>
public partial class ProductsControl : UserControl
{
/// <summary>
/// Elenco completo, indipendente da quello mostrato: la ricerca filtra la vista, non
/// i dati. Senza, scrivere nella casella cancellerebbe le righe invece di nasconderle.
/// </summary>
private IList? _allProducts;
public ProductsControl()
{
InitializeComponent();
}
/// <summary>Prodotto selezionato, o <c>null</c> se la griglia è vuota o senza selezione.</summary>
public ProductViewModel? SelectedProduct => ProductsGrid.SelectedItem as ProductViewModel;
/// <summary>
/// Riempie l'elenco. Le righe sono già ViewModel: la griglia scrive direttamente
/// nelle schede prodotto, e la persistenza avviene alla conferma della cella.
/// </summary>
public void SetProducts(IList products)
{
_allProducts = products;
ApplySearch();
}
/// <summary>
/// Mostra solo i prodotti il cui nome contiene il testo cercato.
///
/// <para>Il filtro si applica a una copia: la griglia è modificabile, e gli oggetti
/// mostrati sono gli stessi dell'elenco completo — quindi i limiti scritti mentre
/// un filtro è attivo restano quando il filtro sparisce.</para>
/// </summary>
private void ApplySearch()
{
if (_allProducts == null) return;
var query = SearchBox.Text?.Trim() ?? "";
SearchPlaceholder.Visibility = query.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
var all = _allProducts.OfType<ProductViewModel>().ToList();
var shown = query.Length == 0
? all
: all.Where(p => (p.DisplayName ?? "")
.Contains(query, System.StringComparison.OrdinalIgnoreCase))
.ToList();
ProductsGrid.ItemsSource = shown;
ProductsCountText.Text = query.Length == 0
? (all.Count == 1 ? "1 prodotto" : $"{all.Count} prodotti")
: $"{shown.Count} di {all.Count}";
ProductsWatchedText.Text = $"{all.Count(p => p.IsWatched)} seguiti";
// Con la ricerca attiva e nessun risultato non si mostra il messaggio di elenco
// vuoto: direbbe "non segui ancora nessun prodotto", che sarebbe falso.
ProductsEmptyState.Visibility = all.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) => ApplySearch();
/// <summary>Riallinea i contatori dopo che una stellina è cambiata.</summary>
public void RefreshCounters()
{
// Si conta sull'elenco completo, non su quello mostrato: con una ricerca attiva
// il numero dei seguiti non deve cambiare solo perché sono nascosti.
var source = _allProducts ?? ProductsGrid.ItemsSource as IList;
if (source == null) return;
ProductsWatchedText.Text = $"{source.OfType<ProductViewModel>().Count(p => p.IsWatched)} seguiti";
}
/// <summary>Porta la selezione sul prodotto indicato (usato arrivando dalla scheda Cerca).</summary>
public void SelectProduct(string identity)
{
if (ProductsGrid.ItemsSource is not IEnumerable items) return;
var match = items.OfType<ProductViewModel>()
.FirstOrDefault(p => p.Identity == identity);
if (match == null) return;
ProductsGrid.SelectedItem = match;
ProductsGrid.ScrollIntoView(match);
}
// ── Eventi dell'interfaccia ──────────────────────────────────────
private void ProductsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ProductSelectionChangedEvent, this));
/// <summary>
/// La cella è stata confermata: il valore è già nel ViewModel, resta da renderlo
/// durevole. Si rimanda al termine dell'operazione perché durante l'evento la
/// modifica non è ancora stata trasferita al modello.
/// </summary>
private void ProductsGrid_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e)
{
if (e.EditAction != DataGridEditAction.Commit) return;
Dispatcher.BeginInvoke(new System.Action(() =>
RaiseEvent(new RoutedEventArgs(ProductLimitsEditedEvent, this))),
System.Windows.Threading.DispatcherPriority.Background);
}
private void RefreshButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RefreshClickedEvent, this));
private void ScanNowButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ScanNowClickedEvent, this));
private void ReapplyButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ReapplyClickedEvent, this));
private void RemoveButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RemoveClickedEvent, this));
private void ClearButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearClickedEvent, this));
private void ClearAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearAllClickedEvent, this));
private void RecalculateButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RecalculateClickedEvent, this));
private void UnwatchAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(UnwatchAllClickedEvent, this));
private void RemoveUnwatchedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RemoveUnwatchedClickedEvent, this));
private void ApplySuggestedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ApplySuggestedClickedEvent, this));
private void ApplyAllSuggestedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ApplyAllSuggestedClickedEvent, this));
// ── Routed events ────────────────────────────────────────────────
public static readonly RoutedEvent RefreshClickedEvent = EventManager.RegisterRoutedEvent(
"RefreshClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ScanNowClickedEvent = EventManager.RegisterRoutedEvent(
"ScanNowClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ReapplyClickedEvent = EventManager.RegisterRoutedEvent(
"ReapplyClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent RemoveClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ClearClickedEvent = EventManager.RegisterRoutedEvent(
"ClearClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ClearAllClickedEvent = EventManager.RegisterRoutedEvent(
"ClearAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent RecalculateClickedEvent = EventManager.RegisterRoutedEvent(
"RecalculateClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent UnwatchAllClickedEvent = EventManager.RegisterRoutedEvent(
"UnwatchAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent RemoveUnwatchedClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveUnwatchedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ApplySuggestedClickedEvent = EventManager.RegisterRoutedEvent(
"ApplySuggestedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ApplyAllSuggestedClickedEvent = EventManager.RegisterRoutedEvent(
"ApplyAllSuggestedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ProductLimitsEditedEvent = EventManager.RegisterRoutedEvent(
"ProductLimitsEdited", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ProductSelectionChangedEvent = EventManager.RegisterRoutedEvent(
"ProductSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public event RoutedEventHandler RefreshClicked
{
add { AddHandler(RefreshClickedEvent, value); }
remove { RemoveHandler(RefreshClickedEvent, value); }
}
public event RoutedEventHandler ScanNowClicked
{
add { AddHandler(ScanNowClickedEvent, value); }
remove { RemoveHandler(ScanNowClickedEvent, value); }
}
public event RoutedEventHandler ReapplyClicked
{
add { AddHandler(ReapplyClickedEvent, value); }
remove { RemoveHandler(ReapplyClickedEvent, value); }
}
public event RoutedEventHandler RemoveClicked
{
add { AddHandler(RemoveClickedEvent, value); }
remove { RemoveHandler(RemoveClickedEvent, value); }
}
public event RoutedEventHandler ClearClicked
{
add { AddHandler(ClearClickedEvent, value); }
remove { RemoveHandler(ClearClickedEvent, value); }
}
public event RoutedEventHandler ClearAllClicked
{
add { AddHandler(ClearAllClickedEvent, value); }
remove { RemoveHandler(ClearAllClickedEvent, value); }
}
public event RoutedEventHandler RecalculateClicked
{
add { AddHandler(RecalculateClickedEvent, value); }
remove { RemoveHandler(RecalculateClickedEvent, value); }
}
public event RoutedEventHandler UnwatchAllClicked
{
add { AddHandler(UnwatchAllClickedEvent, value); }
remove { RemoveHandler(UnwatchAllClickedEvent, value); }
}
public event RoutedEventHandler RemoveUnwatchedClicked
{
add { AddHandler(RemoveUnwatchedClickedEvent, value); }
remove { RemoveHandler(RemoveUnwatchedClickedEvent, value); }
}
public event RoutedEventHandler ApplySuggestedClicked
{
add { AddHandler(ApplySuggestedClickedEvent, value); }
remove { RemoveHandler(ApplySuggestedClickedEvent, value); }
}
public event RoutedEventHandler ApplyAllSuggestedClicked
{
add { AddHandler(ApplyAllSuggestedClickedEvent, value); }
remove { RemoveHandler(ApplyAllSuggestedClickedEvent, value); }
}
public event RoutedEventHandler ProductLimitsEdited
{
add { AddHandler(ProductLimitsEditedEvent, value); }
remove { RemoveHandler(ProductLimitsEditedEvent, value); }
}
public event RoutedEventHandler ProductSelectionChanged
{
add { AddHandler(ProductSelectionChangedEvent, value); }
remove { RemoveHandler(ProductSelectionChangedEvent, value); }
}
}
}
+825
View File
@@ -0,0 +1,825 @@
<UserControl x:Class="AutoBidder.Controls.SettingsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<!-- Stili centralizzati in Themes/Controls.xaml -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Scrollable Content -->
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="30,20">
<!-- SEZIONE 0: Aspetto -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Aspetto"
Margin="0,0,0,14"
Help="Scegli il tema dell'interfaccia. Il cambiamento è immediato."/>
<StackPanel Orientation="Horizontal">
<RadioButton x:Name="ThemeDarkRadio"
Content="Scuro"
GroupName="AppTheme"
IsChecked="True"
Checked="ThemeDarkRadio_Checked"
ToolTip="Tema scuro (consigliato per sessioni lunghe)"/>
<RadioButton x:Name="ThemeLightRadio"
Content="Chiaro"
GroupName="AppTheme"
Checked="ThemeLightRadio_Checked"
ToolTip="Tema chiaro"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE 0-quater: Fascia oraria e pulizia dell'elenco -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Fascia oraria sospesa e pulizia dell'elenco"
Margin="0,0,0,14"
Help="Fascia oraria: su 6060 aste concluse la stessa asta chiude al 4,6% del valore alle 12 e al 5,3% fra le 10 e le 13, ma all&apos;8,6% a mezzanotte e al 9,7% alle 9 — quasi il doppio, e con 26-29 puntate per vincere invece di 21-24. Nella fascia le puntate si sospendono ma l&apos;asta resta Attiva e riprende da sola. Inizio incluso, fine esclusa; una fascia a cavallo di mezzanotte (22-6) funziona; inizio uguale a fine spegne il filtro.&#10;&#10;Pulizia dell&apos;elenco: le aste concluse spariscono da sole dal monitor, tranne quelle che vale la pena riguardare."/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox x:Name="QuietHoursCheckBox" Content="Sospendi le puntate dalle" VerticalAlignment="Center"/>
<TextBox x:Name="QuietHoursStartTextBox" Text="0" Width="56" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Text="alle" VerticalAlignment="Center" Foreground="{DynamicResource Brush.Text}"/>
<TextBox x:Name="QuietHoursEndTextBox" Text="10" Width="56" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Text="(ore, 0-23)" VerticalAlignment="Center" Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
<CheckBox x:Name="AutoRemoveFinishedCheckBox" Content="Togli da sole le aste concluse dall'elenco" Margin="0,4"/>
<CheckBox x:Name="AutoRemoveKeepMyBidsCheckBox" Content="…ma trattieni quelle su cui ho puntato" Margin="20,2,0,0"
ToolTip="Sono le uniche con il costo reale dentro: restano per controllarle a mano."/>
<CheckBox x:Name="AutoRemoveKeepWonCheckBox" Content="…ma trattieni quelle vinte" Margin="20,2,0,0"
ToolTip="C'è da confermare l'acquisto su Bidoo: una vittoria sparita dall'elenco è il modo più semplice di dimenticarsene."/>
<CheckBox x:Name="AutoRemoveKeepUnclearCheckBox" Content="…ma trattieni quelle di cui non si è vista la fine" Margin="20,2,0,0"
ToolTip="Esito da controllare: sono esattamente quelle da guardare."/>
</StackPanel>
</Border>
<!-- SEZIONE 0-bis: Sessione / Autenticazione -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Sessione Bidoo (Autenticazione)"
Margin="0,0,0,14"
Help="Accedi dalla scheda Browser: il cookie viene rilevato e importato da solo, e vale per tutta l&apos;applicazione. La sessione resta salvata e protetta con DPAPI, quindi l&apos;accesso non va rifatto a ogni avvio.&#10;&#10;Un solo modo di accedere e nessun campo per il cookie a mano: due strade vorrebbero dire due sessioni possibili, e la domanda &quot;quale sta usando?&quot; senza risposta."/>
<!-- Stato corrente -->
<Border Background="{DynamicResource Brush.SurfaceAlt}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="6"
Padding="12,10"
Margin="0,0,0,14">
<StackPanel>
<TextBlock x:Name="SessionStatusText"
Text="Non connesso"
Foreground="{DynamicResource Brush.Danger}"
FontWeight="SemiBold"/>
<TextBlock x:Name="SessionStatusDetail"
Text="Nessuna sessione attiva."
Foreground="{DynamicResource Brush.TextMuted}"
FontSize="11.5"
Margin="0,3,0,0"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal">
<Button Content="Apri Browser per il login"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Style="{StaticResource SmallRoundedButton}"
Click="OpenBrowserLoginButton_Click"
Margin="0,0,8,0"/>
<Button x:Name="ConnectSessionButton"
Content="Verifica connessione"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Style="{StaticResource SmallRoundedButton}"
ToolTip="Ricontrolla la sessione salvata contro il sito"
Click="ConnectSessionButton_Click"
Margin="0,0,8,0"/>
<Button x:Name="DisconnectSessionButton"
Content="Disconnetti"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Style="{StaticResource SmallRoundedButton}"
Click="DisconnectSessionButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE 1: Impostazioni Predefinite Aste -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Impostazioni Predefinite Aste"
Margin="0,0,0,14"
Help="Valgono per ogni asta nuova che non abbia limiti propri. I limiti scritti nella scheda Prodotti hanno sempre la precedenza su questi."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo deciso dalla rete" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Acceso: l'anticipo lo sceglie il modello di latenza — margine di sicurezza più la coda alta del ping di questa sessione — e si corregge da solo a ogni puntata tardiva. Si muove solo fra il minimo e il massimo qui sotto.&#10;&#10;Spento: vale l'anticipo fisso della riga seguente.&#10;&#10;In entrambi i casi un anticipo scritto a mano su una singola asta vince sempre."/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="AdaptiveLeadCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anticipo minimo (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Il modello non scende mai sotto questo valore. Sotto i 300 ms un picco di rete fa arrivare la puntata a giochi chiusi: il ping tocca 444 ms nel p99,9."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LeadMinMsTextBox" Text="300" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Anticipo massimo (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Il modello non sale mai sopra questo valore. Ogni millisecondo in più è tempo regalato agli avversari: dalla rigiocata di 2.095 aste, sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="LeadMaxMsTextBox" Text="1500" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Anticipo fisso (millisecondi)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Usato quando l'anticipo deciso dalla rete è spento, e come punto di partenza finché il modello non ha misurato abbastanza ping. Quanti millisecondi prima della scadenza parte la puntata: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato."/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DefaultBidBeforeDeadlineMsTextBox" Text="500" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Prezzo Minimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="DefaultMinPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Prezzo Massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="DefaultMaxPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="Max Click" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quante puntate al massimo spendere su una singola asta. Entra anche nel calcolo dei limiti consigliati per prodotto: ogni puntata in più è denaro che non si può mettere sul prezzo. 0 = illimitate."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="DefaultMaxClicksTextBox" Text="0" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
<!-- SEZIONE: Limiti consigliati per prodotto -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Limiti Consigliati per Prodotto"
Margin="0,0,0,14"
Help="La scheda Prodotti propone, per ogni articolo, un prezzo minimo e uno massimo ricavati da come sono andate le sue aste finora.&#10;&#10;Il massimo nasce da due vincoli e vince il più stretto: il prezzo che copre la quota di chiusure impostata qui sotto, e quanto si può spendere — valore del prodotto, meno il costo delle puntate massime. Il minimo è il decimo percentile delle chiusure: sotto quel prezzo l'asta non si chiude quasi mai, quindi puntare lì è quasi sempre speso a vuoto.&#10;&#10;Perché non basta la moda: il prezzo a cui un articolo si aggiudica più spesso copre fra il 15% e il 44% delle sue aste, a seconda del prodotto. Fermandosi lì si resterebbe fuori dalla maggior parte delle occasioni."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Copertura delle chiusure (%)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="In quante aste passate saresti stato ancora in gioco fermandoti al massimo consigliato.&#10;&#10;Alzarla vuol dire restare in corsa più spesso ma spingersi su prezzi più alti; abbassarla vuol dire puntare solo sulle occasioni buone e lasciar perdere le altre. Fascia sensata: 5095."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="SuggestedCoverageTextBox"
Text="85" Margin="10,10"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Costo medio di una puntata (€)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Quanto ti costa una puntata. Serve a calcolare il tetto di spesa: valore del prodotto meno le puntate massime moltiplicate per questo costo.&#10;&#10;Il valore predefinito è 0,20 €, cioè il pacchetto meno vantaggioso in vendita su Bidoo: tenerlo così fa stimare per eccesso, e una stima per eccesso al massimo ti fa rinunciare a un affare, mentre una per difetto ti fa spendere più del valore del prodotto.&#10;&#10;Se compri pacchetti più grandi, abbassalo al costo che paghi davvero."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="AverageBidCostTextBox"
Text="0,20" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
<!-- SEZIONE 2: Stato Iniziale Aste -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Stato Iniziale Aste"
Margin="0,0,0,14"
Help="Come si comportano le aste quando vengono caricate o aggiunte.
• Fermata: non viene monitorata fino all'avvio manuale.
• Osserva: viene seguita (prezzo, scadenza, avversari) ma non punta mai. Utile per studiarla prima di entrare.
• Attiva: viene monitorata e punta da sola quando serve.
• Ricorda Stato: ogni asta riprende lo stato che aveva alla chiusura, e sovrascrive le altre opzioni.
Consiglio: usa Fermata per caricare senza avviare, poi avvia a mano quelle che vuoi."/>
<!-- Stato all'apertura applicazione -->
<TextBlock Text="Stato aste al caricamento dell'applicazione"
Style="{StaticResource FieldLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<RadioButton x:Name="LoadAuctionsStopped"
Content="Fermate"
GroupName="LoadState"
IsChecked="True"
ToolTip="Le aste salvate verranno caricate in stato fermo"/>
<RadioButton x:Name="LoadAuctionsPaused"
Content="Osserva"
GroupName="LoadState"
ToolTip="Le aste salvate seguiranno prezzo e scadenza senza mai puntare"/>
<RadioButton x:Name="LoadAuctionsActive"
Content="Attive"
GroupName="LoadState"
ToolTip="Le aste salvate verranno avviate automaticamente all'apertura"/>
<RadioButton x:Name="LoadAuctionsRemember"
Content="Ricorda Stato"
GroupName="LoadState"
ToolTip="Ogni asta ripristina lo stato che aveva alla chiusura (attiva/pausa/ferma)"/>
</StackPanel>
<!-- Stato nuove aste -->
<TextBlock Text="Stato iniziale di una nuova asta aggiunta"
Style="{StaticResource FieldLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,0">
<RadioButton x:Name="NewAuctionStopped"
Content="Fermata"
GroupName="NewAuctionState"
IsChecked="True"
ToolTip="Le nuove aste verranno aggiunte in stato fermo"/>
<RadioButton x:Name="NewAuctionPaused"
Content="Osserva"
GroupName="NewAuctionState"
ToolTip="Le nuove aste seguiranno prezzo e scadenza senza mai puntare"/>
<RadioButton x:Name="NewAuctionActive"
Content="Attiva"
GroupName="NewAuctionState"
ToolTip="Le nuove aste verranno avviate automaticamente"/>
</StackPanel>
<!-- Info Box -->
</StackPanel>
</Border>
<!-- SEZIONE: Motore di precisione -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Motore di Precisione"
Margin="0,0,0,14"
Help="Ogni asta ha il proprio motore indipendente e interroga Bidoo al massimo che la rete concede: nessuna cadenza da impostare. Se il server chiede di rallentare (429/503) il motore frena da solo e riparte appena può."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Limite richieste al secondo" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun tetto (consigliato: si va al massimo che la rete concede). Le puntate hanno corsia preferenziale e non sono mai soggette a questo limite."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="MaxRequestsPerSecondTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Timer di sistema a 1 ms" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Senza, ogni attesa può sforare di ~15 ms e l'anticipo perde significato"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="PrecisionTimerCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
<StackPanel>
<TextBlock Text="Diagnostica motore"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Accent}"
Margin="0,0,0,10"/>
<TextBlock x:Name="EngineDiagnosticsText"
Text="Monitoraggio non avviato."
TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="12"
LineHeight="18"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- SEZIONE: Prodotti seguiti (aggiunta automatica) -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Prodotti Seguiti (aggiunta automatica)"
Margin="0,0,0,14"
Help="Bidoo rimette all'asta lo stesso articolo di continuo. Con la stellina in Esplora segui un prodotto: le aste nuove di quell'articolo entrano nel monitor da sole.
Perché il valore consigliato è Osserva: un'asta che entra da sola e comincia subito a puntare spenderebbe puntate vere senza che tu l'abbia mai vista.
L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina e i limiti suoi — prezzo, puntate, orizzonte e stato d'ingresso — che vincono su questi."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Aggiungi le aste nuove da solo"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="AutoAddEnabledCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Stato delle aste aggiunte"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Con cosa entrano nel monitor le aste trovate dalla sorveglianza"/>
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="AutoAddStateCombo"
Margin="10,10" VerticalAlignment="Center">
<ComboBoxItem Content="Osserva (consigliato)" Tag="Watch"/>
<ComboBoxItem Content="Ferma" Tag="Stopped"/>
<ComboBoxItem Content="Attiva — punta subito" Tag="Active"/>
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Cerca aste nuove ogni (secondi)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="AutoAddScanSecondsTextBox"
Text="90" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Massimo aste aggiunte insieme"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="0 = nessun limite"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="AutoAddMaxAuctionsTextBox"
Text="20" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Solo se cominciano entro (minuti)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Un'asta che comincia oltre questo tempo non viene aggiunta ancora: verrà ripresa alla scansione successiva, quando sarà più vicina. Serve a non riempire il monitor di aste che non succederanno per mezza giornata, consumando il tetto qui sopra. A mano puoi sempre aggiungere qualunque asta. 0 = nessun limite."/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="AutoAddMaxStartMinutesTextBox"
Text="60" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Solo aste non ancora cominciate"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Aggiungi da solo soltanto le aste su cui nessuno ha ancora puntato, per poterle seguire dal primo istante all&apos;ultimo.&#10;&#10;Un&apos;asta presa a metà si può seguire, ma la sua storia è già scritta per buona parte: non si sa quanto è durata davvero, chi c&apos;era prima, come è salito il prezzo. Le statistiche che ne escono sono monche e la registrazione racconta solo la seconda metà.&#10;&#10;Il prezzo da pagare è che si perdono le occasioni già in corso."/>
<CheckBox Grid.Row="5" Grid.Column="1" x:Name="AutoAddOnlyNotStartedCheckBox"
Margin="10,10" VerticalAlignment="Center"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Quante aste percorrere nella ricerca"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Il listato è ordinato per scadenza: le aste che devono ancora aprire stanno in fondo. Un valore alto è ciò che permette di prenderle prima che inizino. Abbassarlo fa risparmiare richieste, ma le aste vengono prese solo quando sono già cominciate."/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="AutoAddScanDepthTextBox"
Text="2500" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
<!-- SEZIONE: Aste programmate -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Aste Programmate"
Margin="0,0,0,14"
Help="Un'asta che comincia fra ore non va interrogata ogni due secondi: fino all'apertura non succede nulla. La cadenza si allarga in proporzione all'attesa e torna normale poco prima dell'inizio, così il motore è comunque pronto a puntare."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Rallenta prima dell'apertura"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="ScheduledBackoffCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Oltre un'ora, ogni (secondi)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="ScheduledPollFarTextBox"
Text="1200" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Fra 10 e 60 minuti, ogni (secondi)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="ScheduledPollMidTextBox"
Text="120" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Torna normale (secondi prima)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Margine di sicurezza per non perdere l'apertura"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="ScheduledPollWakeTextBox"
Text="120" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
<!-- SEZIONE: Notifiche -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Notifiche"
Margin="0,0,0,14"
Help="Avvisi di Windows per gli eventi che vale la pena vedere anche a finestra chiusa."/>
<CheckBox x:Name="NotifyOnWinCheckBox" Content="Avvisami quando vinco un'asta"
IsChecked="True" Margin="0,4"/>
<CheckBox x:Name="NotifyOnLossCheckBox"
Content="Avvisami anche quando perdo un'asta su cui avevo puntato"
Margin="0,4"/>
</StackPanel>
</Border>
<!-- SEZIONE: Database -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Database"
Margin="0,0,0,14"
Help="Tutto ciò che l'applicazione registra sta in due file SQLite in questa cartella: le osservazioni (aste, puntate, interrogazioni, decisioni, misure) e l'esercizio (aste nel monitor, prodotti seguiti, modelli appresi, registri). Le impostazioni e la login restano nelle loro cartelle.&#10;&#10;Se cambi cartella, al salvataggio ti chiedo se spostare i database esistenti; in ogni caso l'applicazione si riavvia. Mai una cartella sincronizzata (OneDrive, Dropbox): un database aperto sotto sincronizzazione si corrompe."/>
<TextBlock Text="Cartella dei database" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="DatabaseFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseDatabaseFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri la cartella dei database"
Click="OpenDatabaseFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
</StackPanel>
</Border>
<!-- SEZIONE: Manutenzione -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Manutenzione"
Margin="0,0,0,14"
Help="Le due operazioni estreme. «Elimina tutti i dati» cancella i database, le copie di sicurezza e la vecchia cartella Dati: restano la login e le impostazioni. «Impostazioni di fabbrica» cancella solo il file delle impostazioni: i dati e la login restano. Entrambe chiedono conferma e riavviano l'applicazione."/>
<StackPanel Orientation="Horizontal">
<Button Content="Elimina tutti i dati (tranne la login)" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Margin="0,0,8,0" Padding="14,6"
ToolTip="Cancella i due database (aste, puntate, decisioni, prodotti seguiti, aste nel monitor, modelli appresi, registri), le copie di sicurezza e la vecchia cartella Dati. Restano sessione, riferimenti del sito, browser e impostazioni. Doppia conferma, poi riavvio."
Click="WipeAllDataButton_Click"/>
<Button Content="Impostazioni di fabbrica" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Margin="0,0,8,0" Padding="14,6"
ToolTip="Cancella il file delle impostazioni e riavvia: tutto torna ai predefiniti. Dati e login non vengono toccati."
Click="ResetSettingsButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE: Registri su file -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Registri"
Margin="0,0,0,14"
Help="I registri si scrivono mentre l'applicazione lavora, nel database d'esercizio: quello che vedi nelle schede è già sul disco. Servono a rileggere cosa è successo mentre non guardavi. Si esportano in testo con il pulsante qui sotto e scadono dopo il numero di giorni indicato."/>
<CheckBox x:Name="WriteAppLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro applicativo nel database"/>
<CheckBox x:Name="WriteFreeBidsLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro del riscatto puntate nel database"/>
<CheckBox x:Name="WriteDossiersCheckBox" IsChecked="True" Margin="0,4"
Content="Registra ogni asta seguita nel database (puntate di ogni utente, reset, mie puntate, decisioni)"/>
<CheckBox x:Name="RawPollsCheckBox" IsChecked="True" Margin="0,4"
Content="Registra anche le interrogazioni a Bidoo (una riga quando cambia qualcosa, e almeno una al secondo)"
ToolTip="Risoluzione massima: prezzo, timer e ping a ogni poll. Nella finestra critica sono quattro righe al secondo, quindi i file crescono in fretta."/>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Cancella i registri dopo (giorni)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10"
VerticalAlignment="Center"
ToolTip="0 = tienili per sempre"/>
<TextBox Grid.Column="1" x:Name="LogRetentionTextBox" Text="90" Margin="10,10"/>
</Grid>
<Button Content="Esporta i registri in testo…" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
HorizontalAlignment="Left" Margin="0,6,0,0" Padding="14,5"
ToolTip="Scrive il registro applicativo e quello del riscatto puntate in due file di testo, nella cartella che scegli."
Click="ExportAppLogButton_Click"/>
</StackPanel>
</Border>
<!-- SEZIONE: Catalogo Esplora -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Catalogo (scheda Esplora)"
Margin="0,0,0,14"
Help="L'app scende nel listato pagina per pagina, come il sito quando scorri verso il basso: cinquanta aste per richiesta, fino al tetto impostato qui. L'ordine è per scadenza, quindi le prime pagine sono le aste che chiudono prima. Alzare il tetto allarga la vista ma aggiunge una richiesta ogni cinquanta aste."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Aste da caricare per categoria"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Da 20 a 5000. Una richiesta ogni 50 aste; le categorie piccole finiscono prima e si fermano da sole."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="CatalogMaxAuctionsTextBox"
Text="500" Margin="10,10"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Aggiorna prezzi mentre guardi"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Acceso, prezzi e timer cambiano sotto gli occhi mentre stai leggendo"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="CatalogAutoRefreshCheckBox"
Margin="10,10" VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Riusa le aste scaricate per (secondi)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="0 = nessuna cache. Evita di riscaricare tutte le pagine a ogni cambio categoria."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="CatalogCacheSecondsTextBox"
Text="60" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
<!-- SEZIONE 3: Protezione Account -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Protezione Account"
Margin="0,0,0,14"
Help="Tiene da parte un cuscinetto di puntate sull'account.
Se il valore è maggiore di zero, il sistema non punta quando le puntate residue scenderebbero sotto la soglia. Nel banner principale compare un indicatore: verde oltre le +10 dal limite, giallo entro 10, rosso al limite o sotto (puntate bloccate). Zero = nessun limite."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Puntate Minime da Mantenere -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="Puntate Minime da Mantenere"
Foreground="{DynamicResource Brush.Text}"
Margin="0,10"
VerticalAlignment="Center"
ToolTip="Numero minimo di puntate residue da mantenere sull'account. Se > 0, non punterà se scende sotto questa soglia (0 = nessun limite)"/>
<TextBox Grid.Row="0" Grid.Column="1"
x:Name="MinimumRemainingBidsTextBox"
Text="0"
Margin="10,10"/>
</Grid>
<!-- Info Box -->
</StackPanel>
</Border>
<!-- SEZIONE: Gestione del rischio -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Gestione del rischio"
Margin="0,0,0,14"
Help="I paletti duri che nessuna strategia può scavalcare. Ogni puntata dal vivo passa da qui prima di partire.&#10;&#10;Il tetto di spesa del giorno ferma le puntate per oggi e domani si riparte. Lo stop-loss (perdita netta del giorno) e il drawdown (discesa dal picco del saldo cumulato) mettono invece il sistema in HALT: resta fermo anche dopo un riavvio, finché non lo togli tu da qui. Le vincite si contano al valore reale del prodotto (colonna «Valore reale» nella scheda Prodotti), meno prezzo, fee e spedizione.&#10;&#10;La copertura «Compralo Ora» presume che, perdendo, si compri al listino recuperando le puntate come credito: la perdita si limita a listino valore reale. Per i buoni al nominale copre tutto dal primo centesimo, e il modello punterà più spesso. Se non intendi esercitarla, spegnila."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Tetti di puntate attivi" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Applica i due tetti di puntate qui sotto (per asta e per sessione)."/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="BankrollEnabledCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Puntate massime per asta" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun limite. Vale per le aste senza un tetto proprio."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="MaxBidsPerAuctionTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Puntate massime per sessione" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun limite. Si azzera a ogni avvio."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="MaxBidsPerSessionTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tetto di spesa del giorno (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto spendere in puntate al massimo in un giorno. 0 = nessun limite. Non è un HALT: domani si riparte."/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DailyBudgetTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Stop-loss del giorno (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Perdita netta del giorno (puntate spese meno vincite) oltre la quale il sistema entra in HALT. 0 = mai."/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="DailyStopLossTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Drawdown massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Discesa del saldo cumulato dal suo picco oltre la quale il sistema entra in HALT. 0 = mai."/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="MaxDrawdownTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Aste in gioco insieme" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Su quante aste al massimo spendere nello stesso momento. Un'asta è «in gioco» quando ha già ricevuto una tua puntata in questa sessione. 0 = nessun limite."/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="MaxConcurrentTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="Fee di transazione (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto Bidoo aggiunge alla vincita. Entra nel valore atteso di ogni puntata e nel conto delle vincite."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="TransactionFeeTextBox" Text="1,00" Margin="10,10"/>
<TextBlock Grid.Row="8" Grid.Column="0" Text="Copertura «Compralo Ora»" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Vedi la spiegazione della sezione. Accesa: le puntate oltre la perdita coperta (Compralo Ora meno valore reale) non costano nulla nel ramo di sconfitta.&#10;&#10;Vale solo per i prodotti con «Valore reale €» scritto nella scheda Prodotti: senza, il valore coinciderebbe con il listino, la perdita coperta sarebbe zero e ogni puntata risulterebbe gratis — si punterebbe sempre."/>
<CheckBox Grid.Row="8" Grid.Column="1" x:Name="HedgeCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
<StackPanel>
<TextBlock x:Name="RiskStatusText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="12" LineHeight="18"
Text="Nessun HALT in corso."/>
<Button x:Name="ResumeHaltButton" Content="Togli l'HALT e riparti" Margin="0,10,0,0"
HorizontalAlignment="Left" Padding="14,5" Visibility="Collapsed"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="ResumeHaltButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- SEZIONE 4: Limiti Log -->
<Border Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="4"
Padding="20">
<StackPanel>
<ctl:HelpHeader Title="Limiti Log"
Margin="0,0,0,14"
Help="Quante righe di registro tenere in memoria. Le più vecchie vengono scartate al raggiungimento del limite.
Valori bassi = meno memoria, meno storico a video. Valori alti = più storico, più memoria. Consigliati: 5001000 per asta, 10002000 per il registro globale.
Riguarda solo la memoria: sul file i registri vengono scritti comunque per intero."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="Max Righe Log per Asta"
Foreground="{DynamicResource Brush.Text}"
Margin="0,10"
VerticalAlignment="Center"
ToolTip="Numero massimo di righe di log da mantenere per ogni singola asta (raccomandato: 500-1000)"/>
<TextBox Grid.Row="0" Grid.Column="1"
x:Name="MaxLogLinesPerAuctionTextBox"
Text="500"
Margin="10,10"/>
<TextBlock Grid.Row="1" Grid.Column="0"
Text="Max Righe Log Globale"
Foreground="{DynamicResource Brush.Text}"
Margin="0,10"
VerticalAlignment="Center"
ToolTip="Numero massimo di righe di log da mantenere nel log globale dell'applicazione (raccomandato: 1000-2000)"/>
<TextBox Grid.Row="1" Grid.Column="1"
x:Name="MaxGlobalLogLinesTextBox"
Text="1000"
Margin="10,10"/>
<!-- 📊 NUOVO: Max Storia Puntate -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Max Puntate da Visualizzare"
Foreground="{DynamicResource Brush.Text}"
Margin="0,10"
VerticalAlignment="Center"
ToolTip="Numero massimo di puntate da mostrare nella scheda Storia Puntate (raccomandato: 20-50, 0 = tutte)"/>
<TextBox Grid.Row="2" Grid.Column="1"
x:Name="MaxBidHistoryEntriesTextBox"
Text="20"
Margin="10,10"/>
</Grid>
<!-- Info Box -->
</StackPanel>
</Border>
<!-- SEZIONE 5: Livello di Dettaglio Log -->
<Border Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="4"
Padding="20"
Margin="0,20,0,0">
<StackPanel>
<ctl:HelpHeader Title="Livello di Dettaglio Log"
Margin="0,0,0,14"
Help="Livello minimo dei messaggi mostrati a video. Il file di registro li riceve tutti comunque, perché serve proprio quando il messaggio utile è uno di quelli nascosti.
• Solo Errori: in esercizio, per vedere solo i problemi critici.
• Normale: uso giornaliero. Errori e avvisi importanti.
• Informativo: segue le operazioni principali (aste aggiunte, puntate).
• Debug: parametri delle chiamate e valori interni.
• Trace: ogni singola chiamata. Molto verboso.
Colori: rosso = errori, arancione = avvisi, blu = informazioni, verde = operazioni riuscite, magenta = debug, grigio = trace."/>
<!-- Radio Buttons per livello log -->
<StackPanel>
<RadioButton x:Name="LogLevelErrorOnly"
Content="Solo Errori"
GroupName="LogLevel"
Margin="0,5"
ToolTip="Mostra solo errori critici (uso minimo per produzione)"/>
<RadioButton x:Name="LogLevelNormal"
Content="Normale (Errori + Avvisi)"
GroupName="LogLevel"
IsChecked="True"
Margin="0,5"
ToolTip="Mostra errori e avvisi (uso giornaliero raccomandato)"/>
<RadioButton x:Name="LogLevelInformational"
Content="Informativo (Include operazioni completate)"
GroupName="LogLevel"
Margin="0,5"
ToolTip="Mostra anche messaggi informativi e conferme (uso dettagliato)"/>
<RadioButton x:Name="LogLevelDebug"
Content="Debug (Include dettagli tecnici)"
GroupName="LogLevel"
Margin="0,5"
ToolTip="Mostra anche messaggi di debug per sviluppo"/>
<RadioButton x:Name="LogLevelTrace"
Content="Trace (Tutto - molto verboso)"
GroupName="LogLevel"
Margin="0,5"
ToolTip="Mostra ogni singola operazione (debug avanzato)"/>
</StackPanel>
<!-- Info Box -->
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Fixed Bottom Bar with Save/Cancel -->
<Border Grid.Row="1"
Background="{DynamicResource Brush.SurfaceAlt}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,1,0,0"
Padding="30,15">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Salva"
Background="{DynamicResource Brush.Success}"
Style="{StaticResource ModernButton}"
Margin="0,0,10,0"
Padding="40,10"
Click="SaveAllSettings_Click" Foreground="{DynamicResource Brush.TextOnAccent}"/>
<Button Content="Annulla"
Background="{DynamicResource Brush.SurfaceAlt}"
Style="{StaticResource ModernButton}"
Padding="40,10"
Click="CancelAllSettings_Click"/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+310
View File
@@ -0,0 +1,310 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
namespace AutoBidder.Controls
{
/// <summary>
/// Interaction logic for SettingsControl.xaml
/// </summary>
public partial class SettingsControl : UserControl
{
/// <summary>Evita che i Checked scattino mentre si sincronizza la UI.</summary>
private bool _suppressThemeEvents;
public SettingsControl()
{
InitializeComponent();
Loaded += (_, _) => SyncThemeSelection();
}
/// <summary>
/// Allinea i radio button al tema attualmente applicato.
/// </summary>
public void SyncThemeSelection()
{
_suppressThemeEvents = true;
try
{
bool dark = Utilities.ThemeManager.IsDark;
ThemeDarkRadio.IsChecked = dark;
ThemeLightRadio.IsChecked = !dark;
}
finally
{
_suppressThemeEvents = false;
}
}
private void ThemeDarkRadio_Checked(object sender, RoutedEventArgs e)
{
if (_suppressThemeEvents) return;
Utilities.ThemeManager.SetAndSave(true);
}
private void ThemeLightRadio_Checked(object sender, RoutedEventArgs e)
{
if (_suppressThemeEvents) return;
Utilities.ThemeManager.SetAndSave(false);
}
// Non servono propriet wrapper - MainWindow.xaml.cs accede direttamente ai controlli tramite:
// Settings.DefaultBidBeforeDeadlineMsTextBox (definito nel XAML con x:Name)
// Settings.MaxLogLinesPerAuctionTextBox (definito nel XAML con x:Name)
// etc.
// Propriet per limiti log
public TextBox MaxLogLinesPerAuction => MaxLogLinesPerAuctionTextBox;
public TextBox MaxGlobalLogLines => MaxGlobalLogLinesTextBox;
// ?? NUOVO: Propriet per limite storia puntate
public TextBox MaxBidHistoryEntries => MaxBidHistoryEntriesTextBox;
// ===== ANTICIPO, CARTELLE, ESPORTAZIONE =====
private void BrowseDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseDatabaseFolderClickedEvent, this));
private void OpenDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenDatabaseFolderClickedEvent, this));
public static readonly RoutedEvent BrowseDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler BrowseDatabaseFolderClicked
{
add { AddHandler(BrowseDatabaseFolderClickedEvent, value); }
remove { RemoveHandler(BrowseDatabaseFolderClickedEvent, value); }
}
public event RoutedEventHandler OpenDatabaseFolderClicked
{
add { AddHandler(OpenDatabaseFolderClickedEvent, value); }
remove { RemoveHandler(OpenDatabaseFolderClickedEvent, value); }
}
// ── Gestione del rischio: kill-switch e HALT si applicano subito ──
private void WipeAllDataButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(WipeAllDataClickedEvent, this));
private void ResetSettingsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ResetSettingsClickedEvent, this));
private void ExportAppLogButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ExportAppLogClickedEvent, this));
public static readonly RoutedEvent WipeAllDataClickedEvent = EventManager.RegisterRoutedEvent(
"WipeAllDataClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ResetSettingsClickedEvent = EventManager.RegisterRoutedEvent(
"ResetSettingsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ExportAppLogClickedEvent = EventManager.RegisterRoutedEvent(
"ExportAppLogClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler WipeAllDataClicked
{
add { AddHandler(WipeAllDataClickedEvent, value); }
remove { RemoveHandler(WipeAllDataClickedEvent, value); }
}
public event RoutedEventHandler ResetSettingsClicked
{
add { AddHandler(ResetSettingsClickedEvent, value); }
remove { RemoveHandler(ResetSettingsClickedEvent, value); }
}
public event RoutedEventHandler ExportAppLogClicked
{
add { AddHandler(ExportAppLogClickedEvent, value); }
remove { RemoveHandler(ExportAppLogClickedEvent, value); }
}
private void ResumeHaltButton_Click(object sender, RoutedEventArgs e)
{
Services.RiskManager.Resume();
RefreshRiskStatus();
}
/// <summary>Riallinea lo stato dell'HALT e la contabilità a quello che dice il database.</summary>
public void RefreshRiskStatus()
{
try
{
var halt = Services.RiskManager.HaltReason;
var s = Services.RiskManager.Snapshot();
var conto = $"Oggi: spesi {s.TodaySpent:F2} €, incassati {s.TodayGained:F2} € (netto {s.TodayNet:+0.00;-0.00} €). " +
$"Saldo cumulato {s.Cumulative:+0.00;-0.00} €, picco {s.Peak:F2} €, drawdown {s.Drawdown:F2} €.";
RiskStatusText.Text = string.IsNullOrEmpty(halt)
? "Nessun HALT in corso.\n" + conto
: $"HALT in corso: {halt}\n{conto}";
ResumeHaltButton.Visibility = string.IsNullOrEmpty(halt) ? Visibility.Collapsed : Visibility.Visible;
}
catch { }
finally { }
}
// ===== AGGIUNTA AUTOMATICA =====
//
// L'elenco dei prodotti (stellina e limiti su misura) sta nella scheda Prodotti:
// qui restano solo gli interruttori che valgono per tutti.
/// <summary>Stato con cui entrano le aste aggiunte automaticamente ("Watch"/"Stopped"/"Active").</summary>
public string AutoAddState
{
get => (AutoAddStateCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? "Watch";
set
{
foreach (var item in AutoAddStateCombo.Items.OfType<ComboBoxItem>())
{
if ((item.Tag as string) == value)
{
AutoAddStateCombo.SelectedItem = item;
return;
}
}
AutoAddStateCombo.SelectedIndex = 0;
}
}
// ===== SESSIONE / AUTENTICAZIONE =====
//
// Non c'è più un campo per incollare il cookie a mano: l'accesso si fa dal browser
// integrato e vale per tutta l'applicazione. Un secondo modo significava due
// sessioni possibili e diverse — quella digitata e quella del browser — con la
// domanda "quale sta usando adesso?" senza una risposta visibile.
/// <summary>
/// Aggiorna il riquadro di stato della sessione.
/// </summary>
public void SetSessionStatus(bool connected, string? username = null,
int remainingBids = 0, double shopCredit = 0)
{
if (connected && !string.IsNullOrEmpty(username))
{
SessionStatusText.Text = $"Connesso come {username}";
SessionStatusText.SetResourceReference(ForegroundProperty, "Brush.Success");
SessionStatusDetail.Text =
$"Puntate residue: {remainingBids} • Credito Shop: EUR {shopCredit:F2}";
}
else
{
SessionStatusText.Text = "Non connesso";
SessionStatusText.SetResourceReference(ForegroundProperty, "Brush.Danger");
SessionStatusDetail.Text = "Nessuna sessione attiva.";
}
}
private void ConnectSessionButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ConnectSessionClickedEvent, this));
}
private void DisconnectSessionButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(DisconnectSessionClickedEvent, this));
}
private void OpenBrowserLoginButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(OpenBrowserLoginClickedEvent, this));
}
public static readonly RoutedEvent ConnectSessionClickedEvent = EventManager.RegisterRoutedEvent(
"ConnectSessionClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent DisconnectSessionClickedEvent = EventManager.RegisterRoutedEvent(
"DisconnectSessionClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenBrowserLoginClickedEvent = EventManager.RegisterRoutedEvent(
"OpenBrowserLoginClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler ConnectSessionClicked
{
add { AddHandler(ConnectSessionClickedEvent, value); }
remove { RemoveHandler(ConnectSessionClickedEvent, value); }
}
public event RoutedEventHandler DisconnectSessionClicked
{
add { AddHandler(DisconnectSessionClickedEvent, value); }
remove { RemoveHandler(DisconnectSessionClickedEvent, value); }
}
public event RoutedEventHandler OpenBrowserLoginClicked
{
add { AddHandler(OpenBrowserLoginClickedEvent, value); }
remove { RemoveHandler(OpenBrowserLoginClickedEvent, value); }
}
private void SaveDefaultsButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(SaveDefaultsClickedEvent, this));
}
private void CancelDefaultsButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(CancelDefaultsClickedEvent, this));
}
// Nuovo evento unificato - SALVA TUTTE LE IMPOSTAZIONI
private void SaveAllSettings_Click(object sender, RoutedEventArgs e)
{
try
{
// Salva impostazioni predefinite aste (export rimosso)
RaiseEvent(new RoutedEventArgs(SaveDefaultsClickedEvent, this));
// UNICO MessageBox di conferma
MessageBox.Show(
"Tutte le impostazioni sono state salvate con successo.\n\nLe nuove impostazioni verranno applicate alle aste future.",
"Impostazioni Salvate",
MessageBoxButton.OK,
MessageBoxImage.Information
);
}
catch (System.Exception ex)
{
MessageBox.Show(
"Errore durante il salvataggio: " + ex.Message,
"Errore",
MessageBoxButton.OK,
MessageBoxImage.Error
);
}
}
private void CancelAllSettings_Click(object sender, RoutedEventArgs e)
{
// Annulla tutte le modifiche
RaiseEvent(new RoutedEventArgs(CancelDefaultsClickedEvent, this));
}
// Routed Events
public static readonly RoutedEvent SaveDefaultsClickedEvent = EventManager.RegisterRoutedEvent(
"SaveDefaultsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent CancelDefaultsClickedEvent = EventManager.RegisterRoutedEvent(
"CancelDefaultsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler SaveDefaultsClicked
{
add { AddHandler(SaveDefaultsClickedEvent, value); }
remove { RemoveHandler(SaveDefaultsClickedEvent, value); }
}
public event RoutedEventHandler CancelDefaultsClicked
{
add { AddHandler(CancelDefaultsClickedEvent, value); }
remove { RemoveHandler(CancelDefaultsClickedEvent, value); }
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<UserControl x:Class="AutoBidder.Controls.SimpleToolbar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="48" d:DesignWidth="800">
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter Content="{Binding LeftContent, RelativeSource={RelativeSource AncestorType=UserControl}}" VerticalAlignment="Center" />
<ContentPresenter Grid.Column="1" Content="{Binding RightContent, RelativeSource={RelativeSource AncestorType=UserControl}}" VerticalAlignment="Center" />
</Grid>
</UserControl>
+28
View File
@@ -0,0 +1,28 @@
using System.Windows;
using System.Windows.Controls;
namespace AutoBidder.Controls
{
public partial class SimpleToolbar : UserControl
{
public static readonly DependencyProperty LeftContentProperty = DependencyProperty.Register("LeftContent", typeof(object), typeof(SimpleToolbar));
public static readonly DependencyProperty RightContentProperty = DependencyProperty.Register("RightContent", typeof(object), typeof(SimpleToolbar));
public object? LeftContent
{
get => GetValue(LeftContentProperty);
set => SetValue(LeftContentProperty, value);
}
public object? RightContent
{
get => GetValue(RightContentProperty);
set => SetValue(RightContentProperty, value);
}
public SimpleToolbar()
{
InitializeComponent();
}
}
}
@@ -1,23 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace AutoBidder.Converters
{
public class AndNotPausedConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length == 2 && values[0] is bool isActive && values[1] is bool isPaused)
{
return isActive && !isPaused;
}
return false;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -1,46 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace AutoBidder.Converters
{
// Converte bool in Opacity: true -> 0.5 (disabilitato), false -> 1.0 (abilitato)
public class BoolToOpacityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
bool inverse = parameter?.ToString() == "Inverse";
if (value is bool b)
{
if (inverse)
return b ? 0.5 : 1.0;
else
return b ? 1.0 : 0.5;
}
return 1.0;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
// Converte (IsActive, IsPaused) in Opacity per il pulsante Pausa
public class PauseButtonOpacityConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length == 2 && values[0] is bool isActive && values[1] is bool isPaused)
{
return (isActive && !isPaused) ? 1.0 : 0.5;
}
return 0.5;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
-10
View File
@@ -1,10 +0,0 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:AutoBidder.Converters">
<local:InverseBoolConverter x:Key="InverseBoolConverter"/>
<local:AndNotPausedConverter x:Key="AndNotPausedConverter"/>
<local:StartResumeConverter x:Key="StartResumeConverter"/>
<local:BoolToOpacityConverter x:Key="BoolToOpacityConverter"/>
<local:PauseButtonOpacityConverter x:Key="PauseButtonOpacityConverter"/>
<local:StartButtonOpacityConverter x:Key="StartButtonOpacityConverter"/>
</ResourceDictionary>
@@ -1,23 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace AutoBidder.Converters
{
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool b)
return !b;
return true;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool b)
return !b;
return true;
}
}
}
@@ -1,24 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace AutoBidder.Converters
{
public class StartButtonOpacityConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length == 2 && values[0] is bool isActive && values[1] is bool isPaused)
{
// Bright (1.0) when not active (can start) or when paused (can resume)
return (!isActive || isPaused) ? 1.0 : 0.5;
}
return 1.0;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -1,25 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace AutoBidder.Converters
{
// Returns true when the Start/Resume button for a row should be enabled:
// Enabled when NOT active (stopped) OR when paused (to resume).
public class StartResumeConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length >= 2 && values[0] is bool isActive && values[1] is bool isPaused)
{
return (!isActive) || isPaused;
}
return false;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,163 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Web.WebView2.Core;
namespace AutoBidder
{
/// <summary>
/// Browser event handlers and navigation
/// </summary>
public partial class MainWindow
{
private void BrowserBackButton_Click(object sender, RoutedEventArgs e)
{
try
{
if (EmbeddedWebView?.CoreWebView2 != null && EmbeddedWebView.CoreWebView2.CanGoBack)
EmbeddedWebView.CoreWebView2.GoBack();
}
catch { }
}
private void BrowserForwardButton_Click(object sender, RoutedEventArgs e)
{
try
{
if (EmbeddedWebView?.CoreWebView2 != null && EmbeddedWebView.CoreWebView2.CanGoForward)
EmbeddedWebView.CoreWebView2.GoForward();
}
catch { }
}
private void BrowserRefreshButton_Click(object sender, RoutedEventArgs e)
{
try
{
EmbeddedWebView?.Reload();
}
catch { }
}
private void BrowserHomeButton_Click(object sender, RoutedEventArgs e)
{
try
{
EmbeddedWebView?.CoreWebView2?.Navigate("https://it.bidoo.com/");
BrowserAddress.Text = "https://it.bidoo.com/";
}
catch { }
}
private void BrowserAddAuctionButton_Click(object sender, RoutedEventArgs e)
{
try
{
var url = BrowserAddress.Text?.Trim() ?? EmbeddedWebView?.Source?.ToString();
if (!string.IsNullOrEmpty(url))
_ = AddAuctionFromUrl(url);
}
catch { }
}
private void EmbeddedWebView_NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs e)
{
try
{
BrowserAddress.Text = e.Uri ?? string.Empty;
var btn = this.FindName("BrowserAddAuctionButton") as Button;
if (btn != null)
btn.IsEnabled = IsValidAuctionUrl(e.Uri ?? string.Empty);
}
catch { }
}
private void EmbeddedWebView_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e)
{
try
{
var uri = EmbeddedWebView?.Source?.ToString() ?? BrowserAddress.Text;
BrowserAddress.Text = uri;
var btn = this.FindName("BrowserAddAuctionButton") as Button;
if (btn != null)
btn.IsEnabled = IsValidAuctionUrl(uri);
}
catch { }
}
private void EmbeddedWebView_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
try
{
e.Handled = true;
}
catch { }
}
private void CoreWebView2_ContextMenuRequested(object? sender, CoreWebView2ContextMenuRequestedEventArgs e)
{
try
{
// Prevent default native menu
e.Handled = true;
var target = e.ContextMenuTarget;
string? link = null;
try
{
link = target?.LinkUri;
if (string.IsNullOrEmpty(link)) link = target?.PageUri;
}
catch { }
// Show WPF ContextMenu on UI thread
Dispatcher.BeginInvoke(new Action(() =>
{
try
{
var menu = new ContextMenu();
var canAdd = !string.IsNullOrEmpty(link) || IsValidAuctionUrl(EmbeddedWebView?.Source?.ToString() ?? BrowserAddress.Text);
var addItem = new MenuItem { Header = "Aggiungi Asta", IsEnabled = canAdd };
addItem.Click += async (s, args) =>
{
try
{
string? urlToAdd = link;
if (string.IsNullOrEmpty(urlToAdd))
urlToAdd = EmbeddedWebView?.Source?.ToString() ?? BrowserAddress.Text;
if (!string.IsNullOrEmpty(urlToAdd))
{
await AddAuctionFromUrl(urlToAdd);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Aggiungi Asta dal menu: {ex.Message}");
}
};
menu.Items.Add(addItem);
var copyLink = new MenuItem { Header = "Copia link", IsEnabled = !string.IsNullOrEmpty(link) };
copyLink.Click += (s, args) =>
{
try { if (!string.IsNullOrEmpty(link)) Clipboard.SetText(link); }
catch { }
};
menu.Items.Add(copyLink);
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
menu.IsOpen = true;
}
catch { }
}));
}
catch { }
}
}
}
@@ -0,0 +1,592 @@
using System;
using System.Linq;
using System.Windows;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Settings and configuration event handlers - REFACTORED
/// </summary>
public partial class MainWindow
{
/// <summary>
/// Carica TUTTE le impostazioni salvate nei controlli UI
/// </summary>
private void LoadDefaultSettings()
{
try
{
var settings = Utilities.SettingsManager.Load();
// Carica impostazioni predefinite aste
DefaultBidBeforeDeadlineMs.Text = settings.DefaultBidBeforeDeadlineMs.ToString();
Settings.AdaptiveLeadCheckBox.IsChecked = settings.AdaptiveLeadEnabled;
Settings.LeadMinMsTextBox.Text = settings.LeadMinMs.ToString();
Settings.LeadMaxMsTextBox.Text = settings.LeadMaxMs.ToString();
DefaultMinPrice.Text = settings.DefaultMinPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxPrice.Text = settings.DefaultMaxPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxClicks.Text = settings.DefaultMaxClicks.ToString();
// Carica limiti log
Settings.MaxLogLinesPerAuctionTextBox.Text = settings.MaxLogLinesPerAuction.ToString();
Settings.MaxGlobalLogLinesTextBox.Text = settings.MaxGlobalLogLines.ToString();
// ?? NUOVO: Carica limite storia puntate
Settings.MaxBidHistoryEntriesTextBox.Text = settings.MaxBidHistoryEntries.ToString();
// ?? NUOVO: Carica limite minimo puntate
MinimumRemainingBidsTextBox.Text = settings.MinimumRemainingBids.ToString();
// ?? NUOVO: Carica livello log
var logLevelErrorOnly = Settings.FindName("LogLevelErrorOnly") as System.Windows.Controls.RadioButton;
var logLevelNormal = Settings.FindName("LogLevelNormal") as System.Windows.Controls.RadioButton;
var logLevelInformational = Settings.FindName("LogLevelInformational") as System.Windows.Controls.RadioButton;
var logLevelDebug = Settings.FindName("LogLevelDebug") as System.Windows.Controls.RadioButton;
var logLevelTrace = Settings.FindName("LogLevelTrace") as System.Windows.Controls.RadioButton;
switch (settings.MinLogLevel)
{
case "ErrorOnly":
if (logLevelErrorOnly != null) logLevelErrorOnly.IsChecked = true;
break;
case "Informational":
if (logLevelInformational != null) logLevelInformational.IsChecked = true;
break;
case "Debug":
if (logLevelDebug != null) logLevelDebug.IsChecked = true;
break;
case "Trace":
if (logLevelTrace != null) logLevelTrace.IsChecked = true;
break;
case "Normal":
default:
if (logLevelNormal != null) logLevelNormal.IsChecked = true;
break;
}
// Motore di precisione
Settings.MaxRequestsPerSecondTextBox.Text = settings.MaxRequestsPerSecond.ToString("F0", System.Globalization.CultureInfo.InvariantCulture);
Settings.PrecisionTimerCheckBox.IsChecked = settings.PrecisionTimerEnabled;
RefreshEngineDiagnostics();
// Prodotti seguiti e catalogo
Settings.AutoAddEnabledCheckBox.IsChecked = settings.AutoAddProductsEnabled;
Settings.AutoAddState = settings.AutoAddNewAuctionState;
Settings.AutoAddScanSecondsTextBox.Text = settings.AutoAddScanSeconds.ToString();
Settings.AutoAddMaxAuctionsTextBox.Text = settings.AutoAddMaxAuctions.ToString();
Settings.AutoAddMaxStartMinutesTextBox.Text = settings.AutoAddMaxStartMinutes.ToString();
Settings.AutoAddOnlyNotStartedCheckBox.IsChecked = settings.AutoAddOnlyNotStarted;
Settings.SuggestedCoverageTextBox.Text = settings.SuggestedPriceCoveragePercent.ToString("0", System.Globalization.CultureInfo.CurrentCulture);
Settings.AverageBidCostTextBox.Text = settings.AverageBidCostEuro.ToString("0.00", System.Globalization.CultureInfo.CurrentCulture);
Settings.AutoAddScanDepthTextBox.Text = settings.AutoAddScanMaxAuctions.ToString();
Settings.CatalogMaxAuctionsTextBox.Text = settings.CatalogMaxAuctions.ToString();
Settings.CatalogAutoRefreshCheckBox.IsChecked = settings.CatalogAutoRefresh;
// Aste programmate, notifiche, cartelle
Settings.ScheduledBackoffCheckBox.IsChecked = settings.ScheduledAuctionBackoffEnabled;
Settings.ScheduledPollFarTextBox.Text = settings.ScheduledPollFarSeconds.ToString();
Settings.ScheduledPollMidTextBox.Text = settings.ScheduledPollMidSeconds.ToString();
Settings.ScheduledPollWakeTextBox.Text = settings.ScheduledPollWakeSeconds.ToString();
Settings.NotifyOnWinCheckBox.IsChecked = settings.NotifyOnWin;
Settings.NotifyOnLossCheckBox.IsChecked = settings.NotifyOnLoss;
// I percorsi si mostrano <b>risolti</b>, non come sono salvati: vuoto nel
// file significa "predefinito", ma a video il predefinito ha un nome preciso
// ed è quello che serve sapere.
RefreshDataFolderFields();
Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString();
Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled;
Settings.QuietHoursStartTextBox.Text = settings.QuietHoursStart.ToString();
Settings.QuietHoursEndTextBox.Text = settings.QuietHoursEnd.ToString();
Settings.AutoRemoveFinishedCheckBox.IsChecked = settings.AutoRemoveFinished;
Settings.AutoRemoveKeepMyBidsCheckBox.IsChecked = settings.AutoRemoveKeepWithMyBids;
Settings.AutoRemoveKeepWonCheckBox.IsChecked = settings.AutoRemoveKeepWon;
Settings.AutoRemoveKeepUnclearCheckBox.IsChecked = settings.AutoRemoveKeepUnclear;
Settings.WriteAppLogCheckBox.IsChecked = settings.WriteAppLog;
Settings.WriteFreeBidsLogCheckBox.IsChecked = settings.WriteFreeBidsLog;
Settings.WriteDossiersCheckBox.IsChecked = settings.RecordAuctions;
Settings.RawPollsCheckBox.IsChecked = settings.RecordPolls;
Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString();
Settings.DatabaseFolderTextBox.Text = AppPaths.DatabaseFolder;
Settings.BankrollEnabledCheckBox.IsChecked = settings.BankrollManagerEnabled;
Settings.MaxBidsPerAuctionTextBox.Text = settings.MaxBidsPerAuction.ToString();
Settings.MaxBidsPerSessionTextBox.Text = settings.MaxBidsPerSession.ToString();
Settings.DailyBudgetTextBox.Text = settings.DailyBudgetEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.DailyStopLossTextBox.Text = settings.DailyStopLossEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.MaxDrawdownTextBox.Text = settings.MaxDrawdownEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.MaxConcurrentTextBox.Text = settings.MaxConcurrentActiveAuctions.ToString();
Settings.TransactionFeeTextBox.Text = settings.TransactionFeeEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.HedgeCheckBox.IsChecked = settings.BuyNowHedgeEnabled;
Settings.RefreshRiskStatus();
// Aggiorna indicatore visivo
UpdateMinBidsIndicator(settings.MinimumRemainingBids);
// Carica stato iniziale aste
// ? NUOVO: Se RememberAuctionStates attivo, seleziona "Ricorda Stato"
if (settings.RememberAuctionStates)
{
Settings.LoadAuctionsRemember.IsChecked = true;
}
else
{
// Altrimenti usa DefaultStartAuctionsOnLoad
switch (settings.DefaultStartAuctionsOnLoad)
{
case "Active":
Settings.LoadAuctionsActive.IsChecked = true;
break;
case "Paused":
Settings.LoadAuctionsPaused.IsChecked = true;
break;
case "Stopped":
default:
Settings.LoadAuctionsStopped.IsChecked = true;
break;
}
}
switch (settings.DefaultNewAuctionState)
{
case "Active":
Settings.NewAuctionActive.IsChecked = true;
break;
case "Paused":
Settings.NewAuctionPaused.IsChecked = true;
break;
case "Stopped":
default:
Settings.NewAuctionStopped.IsChecked = true;
break;
}
Log($"[OK] Impostazioni caricate: Anticipo={settings.DefaultBidBeforeDeadlineMs}ms, LogAsta={settings.MaxLogLinesPerAuction}, LogGlobale={settings.MaxGlobalLogLines}, MinBids={settings.MinimumRemainingBids}", Utilities.LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Caricamento impostazioni predefinite: {ex.Message}", Utilities.LogLevel.Error);
}
}
private void SaveDefaultsButton_Click(object sender, RoutedEventArgs e)
{
try
{
// ? Carica le impostazioni esistenti per non perdere gli altri valori
var settings = Utilities.SettingsManager.Load() ?? new Utilities.AppSettings();
// === SEZIONE DEFAULTS: Validazione e Salvataggio ===
if (int.TryParse(DefaultBidBeforeDeadlineMs.Text, out var bidMs) && bidMs >= 0 && bidMs <= 5000)
{
settings.DefaultBidBeforeDeadlineMs = bidMs;
}
else
{
Log("[ERRORE] Valore anticipo puntata non valido (deve essere 0-5000ms)", LogLevel.Error);
return;
}
// Paletti dell'anticipo adattivo: minimo sotto il massimo, entrambi sensati.
settings.AdaptiveLeadEnabled = Settings.AdaptiveLeadCheckBox.IsChecked == true;
settings.LeadMinMs = ReadBounded(Settings.LeadMinMsTextBox.Text, settings.LeadMinMs, 100, 5000, "anticipo minimo", " ms");
settings.LeadMaxMs = ReadBounded(Settings.LeadMaxMsTextBox.Text, settings.LeadMaxMs, 100, 5000, "anticipo massimo", " ms");
if (settings.LeadMaxMs < settings.LeadMinMs)
{
Log($"[ERRORE] Anticipo massimo ({settings.LeadMaxMs} ms) sotto il minimo ({settings.LeadMinMs} ms): riportato al minimo", LogLevel.Error);
settings.LeadMaxMs = settings.LeadMinMs;
}
if (double.TryParse(DefaultMinPrice.Text.Replace(',', '.'), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var minPrice))
{
settings.DefaultMinPrice = minPrice;
}
if (double.TryParse(DefaultMaxPrice.Text.Replace(',', '.'), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var maxPrice))
{
settings.DefaultMaxPrice = maxPrice;
}
if (int.TryParse(DefaultMaxClicks.Text, out var maxClicks))
{
settings.DefaultMaxClicks = maxClicks;
}
// === SEZIONE DEFAULTS: Limiti Log ===
if (int.TryParse(Settings.MaxLogLinesPerAuctionTextBox.Text, out var maxLogPerAuction) && maxLogPerAuction > 0)
{
settings.MaxLogLinesPerAuction = maxLogPerAuction;
}
else
{
Log("[ERRORE] Valore max log per asta non valido (deve essere > 0)", LogLevel.Error);
}
if (int.TryParse(Settings.MaxGlobalLogLinesTextBox.Text, out var maxGlobalLog) && maxGlobalLog > 0)
{
settings.MaxGlobalLogLines = maxGlobalLog;
}
else
{
Log("[ERRORE] Valore max log globale non valido (deve essere > 0)", LogLevel.Error);
}
// ?? NUOVO: Salva limite storia puntate
if (int.TryParse(Settings.MaxBidHistoryEntriesTextBox.Text, out var maxBidHistory) && maxBidHistory >= 0)
{
settings.MaxBidHistoryEntries = maxBidHistory;
if (maxBidHistory > 0)
{
Log($"[HISTORY] Impostato limite storia puntate: {maxBidHistory}", LogLevel.Info);
}
else
{
Log("[HISTORY] Limite storia puntate disabilitato (mostra tutte)", LogLevel.Info);
}
}
else
{
Log("[ERRORE] Valore limite storia puntate non valido (deve essere >= 0)", LogLevel.Error);
}
// ?? NUOVO: Salva limite minimo puntate
if (int.TryParse(MinimumRemainingBidsTextBox.Text, out var minBids) && minBids >= 0)
{
settings.MinimumRemainingBids = minBids;
// Aggiorna indicatore visivo
UpdateMinBidsIndicator(minBids);
if (minBids > 0)
{
Log($"[LIMIT] Impostato limite minimo puntate: {minBids}", LogLevel.Info);
}
}
else
{
Log("[ERRORE] Valore limite minimo puntate non valido (deve essere >= 0)", LogLevel.Error);
}
// ?? NUOVO: Salva livello log
var logLevelErrorOnly = Settings.FindName("LogLevelErrorOnly") as System.Windows.Controls.RadioButton;
var logLevelNormal = Settings.FindName("LogLevelNormal") as System.Windows.Controls.RadioButton;
var logLevelInformational = Settings.FindName("LogLevelInformational") as System.Windows.Controls.RadioButton;
var logLevelDebug = Settings.FindName("LogLevelDebug") as System.Windows.Controls.RadioButton;
var logLevelTrace = Settings.FindName("LogLevelTrace") as System.Windows.Controls.RadioButton;
string selectedLogLevel = "Normal"; // Default
if (logLevelErrorOnly?.IsChecked == true)
selectedLogLevel = "ErrorOnly";
else if (logLevelInformational?.IsChecked == true)
selectedLogLevel = "Informational";
else if (logLevelDebug?.IsChecked == true)
selectedLogLevel = "Debug";
else if (logLevelTrace?.IsChecked == true)
selectedLogLevel = "Trace";
else if (logLevelNormal?.IsChecked == true)
selectedLogLevel = "Normal";
settings.MinLogLevel = selectedLogLevel;
Log($"[LOG] Livello log impostato: {selectedLogLevel}", LogLevel.Info);
// === SEZIONE DEFAULTS: Stati Iniziali Aste ===
var loadAuctionsRemember = Settings.FindName("LoadAuctionsRemember") as System.Windows.Controls.RadioButton;
var loadAuctionsActive = Settings.FindName("LoadAuctionsActive") as System.Windows.Controls.RadioButton;
var loadAuctionsPaused = Settings.FindName("LoadAuctionsPaused") as System.Windows.Controls.RadioButton;
// ? NUOVO: Gestione "Ricorda Stato"
if (loadAuctionsRemember?.IsChecked == true)
{
// Attiva RememberAuctionStates
settings.RememberAuctionStates = true;
// DefaultStartAuctionsOnLoad diventa irrilevante, ma lo lasciamo a "Stopped" per compatibilit
settings.DefaultStartAuctionsOnLoad = "Stopped";
}
else
{
// Disattiva RememberAuctionStates e usa DefaultStartAuctionsOnLoad
settings.RememberAuctionStates = false;
settings.DefaultStartAuctionsOnLoad = loadAuctionsActive?.IsChecked == true ? "Active" :
loadAuctionsPaused?.IsChecked == true ? "Paused" :
"Stopped";
}
var newAuctionActive = Settings.FindName("NewAuctionActive") as System.Windows.Controls.RadioButton;
var newAuctionPaused = Settings.FindName("NewAuctionPaused") as System.Windows.Controls.RadioButton;
settings.DefaultNewAuctionState = newAuctionActive?.IsChecked == true ? "Active" :
newAuctionPaused?.IsChecked == true ? "Paused" :
"Stopped";
// === SEZIONE: Motore di precisione ===
// Ogni cadenza ha un minimo sensato: sotto i 100 ms si spreca banda senza
// guadagnare precisione, perché la puntata la decide il cecchino, non il poll.
if (double.TryParse(Settings.MaxRequestsPerSecondTextBox.Text.Replace(',', '.'),
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var rps) && rps >= 1 && rps <= 200)
{
settings.MaxRequestsPerSecond = rps;
}
else
{
Log("[ERRORE] Limite richieste al secondo non valido (1-200): valore precedente mantenuto", LogLevel.Error);
}
settings.PrecisionTimerEnabled = Settings.PrecisionTimerCheckBox.IsChecked ?? true;
// === SEZIONE: Prodotti seguiti ===
settings.AutoAddProductsEnabled = Settings.AutoAddEnabledCheckBox.IsChecked ?? true;
settings.AutoAddNewAuctionState = Settings.AutoAddState;
settings.AutoAddScanSeconds = ReadBounded(Settings.AutoAddScanSecondsTextBox.Text,
settings.AutoAddScanSeconds, 30, 3600, "intervallo scansione prodotti seguiti", " s");
if (int.TryParse(Settings.AutoAddMaxAuctionsTextBox.Text, out var autoMax) && autoMax >= 0)
settings.AutoAddMaxAuctions = autoMax;
else
Log("[ERRORE] Massimo aste aggiunte automaticamente non valido (>= 0)", LogLevel.Error);
settings.AutoAddScanMaxAuctions = ReadBounded(Settings.AutoAddScanDepthTextBox.Text,
settings.AutoAddScanMaxAuctions, 50, 6000, "profondita ricerca prodotti seguiti", " aste");
// 0 = nessun orizzonte, ed e' una scelta vera: per questo il minimo del
// controllo di validita' e' zero e non uno.
settings.AutoAddOnlyNotStarted = Settings.AutoAddOnlyNotStartedCheckBox.IsChecked ?? false;
// Limiti consigliati per prodotto: due valori che entrano nel calcolo del
// tetto di prezzo. Si accetta la virgola oltre al punto, come nel resto
// dell'applicazione: chi scrive "0,20" non deve vedersi rifiutare il valore.
if (TryReadDouble(Settings.SuggestedCoverageTextBox.Text, out var copertura) &&
copertura is >= 10 and <= 99)
settings.SuggestedPriceCoveragePercent = copertura;
else
Log("[ERRORE] Copertura delle chiusure non valida (10-99%)", LogLevel.Error);
if (TryReadDouble(Settings.AverageBidCostTextBox.Text, out var costo) &&
costo is > 0 and <= 5)
settings.AverageBidCostEuro = costo;
else
Log("[ERRORE] Costo medio di una puntata non valido (0-5 EUR)", LogLevel.Error);
if (int.TryParse(Settings.AutoAddMaxStartMinutesTextBox.Text, out var orizzonte) && orizzonte >= 0)
settings.AutoAddMaxStartMinutes = orizzonte;
else
Log("[ERRORE] Orizzonte di aggiunta automatica non valido (>= 0 minuti)", LogLevel.Error);
if (settings.AutoAddProductsEnabled && settings.AutoAddNewAuctionState == "Active")
{
Log("[ATTENZIONE] Le aste seguite entreranno in stato Attiva: punteranno da sole, spendendo puntate reali.",
LogLevel.Warning);
}
// === SEZIONE: Catalogo ===
settings.CatalogMaxAuctions = ReadBounded(Settings.CatalogMaxAuctionsTextBox.Text,
settings.CatalogMaxAuctions, 20, 5000, "numero massimo aste catalogo", " aste");
settings.CatalogAutoRefresh = Settings.CatalogAutoRefreshCheckBox.IsChecked ?? false;
settings.CatalogCacheSeconds = ReadBounded(Settings.CatalogCacheSecondsTextBox.Text,
settings.CatalogCacheSeconds, 0, 3600, "cache catalogo", " s");
// === SEZIONE: Aste programmate ===
settings.ScheduledAuctionBackoffEnabled = Settings.ScheduledBackoffCheckBox.IsChecked ?? true;
settings.ScheduledPollFarSeconds = ReadBounded(Settings.ScheduledPollFarTextBox.Text,
settings.ScheduledPollFarSeconds, 60, 3600, "polling aste lontane", " s");
settings.ScheduledPollMidSeconds = ReadBounded(Settings.ScheduledPollMidTextBox.Text,
settings.ScheduledPollMidSeconds, 30, 1800, "polling aste vicine all'apertura", " s");
settings.ScheduledPollWakeSeconds = ReadBounded(Settings.ScheduledPollWakeTextBox.Text,
settings.ScheduledPollWakeSeconds, 30, 600, "margine di risveglio", " s");
// === SEZIONE: Notifiche ===
settings.NotifyOnWin = Settings.NotifyOnWinCheckBox.IsChecked ?? true;
settings.NotifyOnLoss = Settings.NotifyOnLossCheckBox.IsChecked ?? false;
// === SEZIONE: Cartelle dei dati ===
var previousDatabaseFolder = AppPaths.DatabaseFolder;
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23)
settings.QuietHoursStart = qStart;
if (int.TryParse(Settings.QuietHoursEndTextBox.Text?.Trim(), out var qEnd) && qEnd is >= 0 and <= 24)
settings.QuietHoursEnd = qEnd;
settings.AutoRemoveFinished = Settings.AutoRemoveFinishedCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepWithMyBids = Settings.AutoRemoveKeepMyBidsCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepWon = Settings.AutoRemoveKeepWonCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepUnclear = Settings.AutoRemoveKeepUnclearCheckBox.IsChecked ?? true;
settings.WriteAppLog = Settings.WriteAppLogCheckBox.IsChecked ?? true;
settings.WriteFreeBidsLog = Settings.WriteFreeBidsLogCheckBox.IsChecked ?? true;
settings.RecordAuctions = Settings.WriteDossiersCheckBox.IsChecked ?? true;
settings.RecordPolls = Settings.RawPollsCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.LogRetentionTextBox.Text?.Trim(), out var retention) && retention >= 0)
settings.LogRetentionDays = retention;
settings.DatabaseFolder = NormalizeFolderChoice(Settings.DatabaseFolderTextBox.Text, AppPaths.DatabaseFolder, settings.DatabaseFolder);
settings.BankrollManagerEnabled = Settings.BankrollEnabledCheckBox.IsChecked ?? true;
settings.MaxBidsPerAuction = ReadBounded(Settings.MaxBidsPerAuctionTextBox.Text, settings.MaxBidsPerAuction, 0, 100000, "puntate massime per asta", "");
settings.MaxBidsPerSession = ReadBounded(Settings.MaxBidsPerSessionTextBox.Text, settings.MaxBidsPerSession, 0, 1000000, "puntate massime per sessione", "");
settings.DailyBudgetEuro = ReadEuro(Settings.DailyBudgetTextBox.Text, settings.DailyBudgetEuro, "tetto di spesa del giorno");
settings.DailyStopLossEuro = ReadEuro(Settings.DailyStopLossTextBox.Text, settings.DailyStopLossEuro, "stop-loss del giorno");
settings.MaxDrawdownEuro = ReadEuro(Settings.MaxDrawdownTextBox.Text, settings.MaxDrawdownEuro, "drawdown massimo");
settings.MaxConcurrentActiveAuctions = ReadBounded(Settings.MaxConcurrentTextBox.Text, settings.MaxConcurrentActiveAuctions, 0, 1000, "aste in gioco insieme", "");
settings.TransactionFeeEuro = ReadEuro(Settings.TransactionFeeTextBox.Text, settings.TransactionFeeEuro, "fee di transazione");
settings.BuyNowHedgeEnabled = Settings.HedgeCheckBox.IsChecked ?? true;
Utilities.SettingsManager.Save(settings);
ApplyDatabaseFolderSetting(settings, previousDatabaseFolder);
// Il limite di ritmo si applica a caldo, senza ricreare il trasporto.
_auctionMonitor.ApplyTransportSettings(settings);
ApplyProductWatchSettings(settings);
// L'interruttore in Esplora non deve dissentire dalle impostazioni, e il
// ciclo va fermato o riavviato di conseguenza.
Browser.SetAutoRefresh(settings.CatalogAutoRefresh);
if (settings.CatalogAutoRefresh) StartCatalogAutoRefresh();
else StopCatalogAutoRefresh();
RefreshEngineDiagnostics();
}
catch (Exception ex)
{
Log($"[ERRORE] Salvataggio impostazioni: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Legge un intero entro limiti, mantenendo il valore precedente se il testo non è
/// utilizzabile: un campo sbagliato non deve far ripartire il motore a caso.
/// </summary>
/// <summary>
/// Legge un numero con la virgola o col punto. Le impostazioni si scrivono a mano
/// in un'applicazione italiana: rifiutare "0,20" sarebbe un dispetto.
/// </summary>
private static bool TryReadDouble(string? text, out double value)
{
value = 0;
if (string.IsNullOrWhiteSpace(text)) return false;
return double.TryParse(text.Trim().Replace(',', '.'),
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out value);
}
private int ReadBounded(string text, int current, int min, int max, string label, string unit = "ms")
{
if (int.TryParse(text, out var value) && value >= min && value <= max)
return value;
Log($"[ERRORE] Valore {label} non valido ({min}-{max}{unit}): mantenuto {current}{unit}", LogLevel.Error);
return current;
}
/// <summary>Un importo in euro, con virgola o punto; se non valido si tiene il valore attuale.</summary>
private double ReadEuro(string? text, double current, string label)
{
var t = (text ?? "").Trim().Replace(',', '.');
if (double.TryParse(t, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) && v >= 0 && v < 1_000_000)
return Math.Round(v, 2);
Log($"[ERRORE] Valore {label} non valido: mantenuto {current:F2} €", LogLevel.Error);
return current;
}
/// <summary>
/// Mostra lo stato del motore: aggancio all'orologio del server e traffico prodotto.
/// </summary>
private void RefreshEngineDiagnostics()
{
try
{
var box = Settings.EngineDiagnosticsText;
if (box == null) return;
var clock = _auctionMonitor.Clock;
var sent = _auctionMonitor.RequestsSent;
var failed = _auctionMonitor.RequestsFailed;
// Attenzione a come si presenta lo scarto: Bidoo dichiara i secondi interi,
// quindi i campioni si distribuiscono per forza su una finestra di circa
// 1000 ms. Non è l'errore della stima — la stima usa il minimo, che converge
// al confine reale del secondo. Un valore molto oltre i 1000 ms segnala
// invece una rete instabile.
var clockLine = clock.IsSynced
? $"Orologio server agganciato su {clock.SampleCount} campioni: le scadenze seguono il server, non l'orologio locale.\n" +
$"Finestra campioni {clock.SpreadMs:F0} ms (intorno a 1000 ms è normale: Bidoo dichiara i secondi interi)."
: $"Orologio server in sincronizzazione ({clock.SampleCount}/3 campioni): finché non è agganciato la scadenza è stimata localmente.";
box.Text = $"{clockLine}\n" +
$"Richieste inviate: {sent:N0} — fallite: {failed:N0}.\n" +
$"Motori attivi: {_auctionMonitor.ActiveRunners}.";
}
catch { /* la diagnostica non deve mai far fallire il salvataggio */ }
}
private void CancelDefaultsButton_Click(object sender, RoutedEventArgs e)
{
try
{
// Ricarica defaults salvati
LoadDefaultSettings();
MessageBox.Show(this, "Impostazioni predefinite ripristinate.", "Annulla", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
Log($"[ERRORE] Ripristino impostazioni: {ex.Message}", LogLevel.Error);
MessageBox.Show(this, "Errore durante ripristino: " + ex.Message, "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// === HANDLER PER PULSANTI UNIFICATI ===
private void SaveAllSettings_Click(object sender, RoutedEventArgs e)
{
try
{
// Salva tutte le impostazioni (ora solo defaults, export rimosso)
SaveDefaultsButton_Click(sender, e);
MessageBox.Show(
"Tutte le impostazioni sono state salvate con successo.\n\nLe nuove impostazioni verranno applicate alle aste future.",
"Impostazioni Salvate",
MessageBoxButton.OK,
MessageBoxImage.Information
);
}
catch (Exception ex)
{
Log($"[ERRORE] Salvataggio impostazioni: {ex.Message}", LogLevel.Error);
MessageBox.Show(this, "Errore durante salvataggio: " + ex.Message, "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void CancelAllSettings_Click(object sender, RoutedEventArgs e)
{
try
{
// Annulla tutte le modifiche
LoadDefaultSettings();
MessageBox.Show(this, "Impostazioni ripristinate alle ultime salvate.", "Annulla", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
Log($"[ERRORE] Ripristino impostazioni: {ex.Message}", LogLevel.Error);
MessageBox.Show(this, "Errore durante ripristino: " + ex.Message, "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Registrazione delle aste concluse.
///
/// Ne escono due archivi con scopi diversi: un riepilogo compatto che alimenta la
/// pagina Statistiche, e una scheda dettagliata per ogni asta destinata alle analisi
/// successive. Il secondo esiste perché quei dati, se non raccolti mentre l'asta è in
/// corso, non sono più recuperabili: Bidoo non li espone a posteriori.
/// </summary>
public partial class MainWindow
{
private void AuctionMonitor_OnAuctionCompleted(AuctionInfo auction, AuctionState state, bool won)
{
try
{
var settings = SettingsManager.Load();
var record = new CompletedAuctionRecord
{
AuctionId = auction.AuctionId,
Name = string.IsNullOrWhiteSpace(auction.Name) ? auction.AuctionId : auction.Name,
ProductKey = ProductKeyHelper.GenerateProductKey(auction.Name),
OriginalUrl = auction.OriginalUrl,
FinalPrice = state?.Price ?? 0,
Winner = state?.LastBidder ?? "",
WonByMe = won,
Outcome = won ? "Vinta"
: (state?.Status == AuctionStatus.EndedLost ? "Persa" : "Chiusa"),
MyBidsUsed = auction.BidsUsedOnThisAuction ?? 0,
TotalResets = auction.ResetCount,
BuyNowPrice = auction.BuyNowPrice,
AddedAt = auction.AddedAt,
// Le puntate spese dal vincitore arrivano dal server insieme allo
// stato finale: e' il numero che il sito mostra come "Puntate
// utilizzate", e l'unico affidabile (lo storico si ferma a cinquanta).
WinnerBidsPaid = state?.WinnerBidsPaid,
WinnerBidsFree = state?.WinnerBidsFree,
// L'ora della fine vera, non quella della registrazione. Un'asta
// terminata resta nel monitor finché non la si toglie: prendere
// DateTime.Now qui significherebbe datare la chiusura al momento in cui
// si è fatto pulizia, magari il giorno dopo.
EndedAt = ResolveEndedAt(auction, state)
};
CompletedAuctionsStore.Append(record);
// Un dato incoerente non va nelle medie: si dice e si scarta, invece di
// spostare in silenzio i limiti di prezzo del prodotto.
var integrity = AuctionIntegrity.CheckWinnerBids(record);
if (record.WinnerBidsUsed.HasValue && !integrity.IsTrustworthy)
{
Log($"[STATISTICHE] {record.Name}: puntate del vincitore scartate ({integrity.Reason})",
LogLevel.Warning);
}
else if (integrity.IsTrustworthy)
{
Log($"[STATISTICHE] {record.Name}: vinta con {record.WinnerBidsUsed} puntate " +
$"(prezzo {record.FinalPrice:F2} EUR)", LogLevel.Info);
}
// La scheda dettagliata vive solo nel riepilogo del dossier: l'archivio
// mensile che la duplicava non c'è più (vedi AuctionDetailStore).
var detail = BuildDetail(auction, state, record);
// La registrazione si chiude con il riepilogo: da lì in poi la riga è una
// storia completa, ed è così che l'analisi sa di poterla usare.
Data.AuctionRecorder.Close(auction, detail, state);
ProductStatsStore.RecordCompleted(record, detail);
// L'apprendimento impara subito da questa asta: il dossier è già su disco
// (Close lo svuota) e lo storico è già scritto. Fuori dal percorso di
// chiusura, che deve restare corto.
_ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record));
NotifyOutcome(record, settings);
Dispatcher.BeginInvoke(() =>
{
Log($"[STORICO] Asta '{record.Name}' salvata ({record.Outcome}, €{record.FinalPrice:F2})",
won ? LogLevel.Success : LogLevel.Info);
// Una vincita è il solo momento in cui il conto delle aste da confermare
// cambia: chiederlo subito evita che la barra resti indietro fino al
// giro successivo del timer.
if (won) _ = RefreshAuctionsToConfirmAsync();
if (StatisticsPanel != null && StatisticsPanel.Visibility == Visibility.Visible)
{
LoadStatistics();
}
// Ultimo passo, e non prima: lo storico è già scritto, il dossier
// chiuso, le statistiche del prodotto aggiornate. Togliere l'asta
// dall'elenco a questo punto non perde niente — tutto ciò che aveva
// da dire è già altrove.
RimuoviSeConclusaEDaTogliere(auction, won, settings);
});
}
catch (Exception ex)
{
Console.WriteLine($"[STORICO ERROR] {ex.Message}");
}
}
/// <summary>
/// Toglie dall'elenco l'asta appena conclusa, se la regola lo consente.
///
/// <para>Con l'aggiunta automatica accesa le aste concluse si accumulano a centinaia
/// e seppelliscono quelle vive, che è il contrario di quello che serve guardando un
/// cruscotto. Restano però quelle su cui si è speso: sono le uniche che vale la pena
/// riguardare, e i soldi erano veri. Vedi <see cref="FinishedAuctionCleanup"/>.</para>
///
/// <para>Il motivo finisce sempre nel registro, anche quando l'asta resta: senza,
/// un'asta che sparisce o che non sparisce sarebbero entrambe inspiegabili.</para>
/// </summary>
private void RimuoviSeConclusaEDaTogliere(AuctionInfo auction, bool won, AppSettings settings)
{
try
{
var verdict = FinishedAuctionCleanup.Decide(auction, won, new FinishedAuctionCleanup.Options
{
Enabled = settings.AutoRemoveFinished,
KeepWithMyBids = settings.AutoRemoveKeepWithMyBids,
KeepWon = settings.AutoRemoveKeepWon,
KeepUnclear = settings.AutoRemoveKeepUnclear
});
if (!verdict.Remove)
{
if (settings.AutoRemoveFinished)
Log($"[ELENCO] '{auction.Name}' resta in elenco: {verdict.Reason}", LogLevel.Info);
return;
}
// Stesso giro della rimozione a mano: prima il motore, poi la griglia,
// poi il salvataggio. Toglierla solo dalla griglia lascerebbe il runner
// a interrogare un'asta che non esiste piu' per chi guarda.
_auctionMonitor.RemoveAuction(auction.AuctionId);
var vm = _auctionViewModels.FirstOrDefault(x => x.AuctionId == auction.AuctionId);
if (vm != null) _auctionViewModels.Remove(vm);
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
Log($"[ELENCO] '{auction.Name}' tolta dall'elenco: {verdict.Reason}", LogLevel.Info);
}
catch (Exception ex)
{
Console.WriteLine($"[ELENCO ERROR] {ex.Message}");
}
}
/// <summary>
/// Quando l'asta è finita davvero, in ordine di attendibilità: l'istante fissato dal
/// monitor quando ha visto la conclusione dal vivo; la scadenza dichiarata dal
/// server nell'ultima risposta; l'istante di quella risposta. Solo se non c'è
/// nessuna delle tre si ripiega sull'ora corrente.
///
/// <para>La differenza non è cosmetica: lo storico si legge per capire a che ora del
/// giorno chiudono le aste che conviene seguire, e una data presa al momento della
/// rimozione rende quel numero privo di significato.</para>
/// </summary>
private static DateTime ResolveEndedAt(AuctionInfo auction, AuctionState? state)
{
if (auction.ConcludedAt.HasValue) return auction.ConcludedAt.Value;
if (state != null && state.ExpiryUnixSeconds > 0)
return DateTimeOffset.FromUnixTimeSeconds(state.ExpiryUnixSeconds).ToLocalTime().DateTime;
if (state != null && state.SnapshotTime != default)
return state.SnapshotTime.ToLocalTime();
return DateTime.Now;
}
/// <summary>
/// Costruisce la scheda dettagliata a partire da ciò che il monitor ha osservato.
/// </summary>
private AuctionDetailRecord BuildDetail(AuctionInfo auction, AuctionState? state, CompletedAuctionRecord summary)
{
var bidders = auction.SnapshotBidderStats();
var bidsByUser = bidders.ToDictionary(b => b.Username, b => b.BidCount, StringComparer.OrdinalIgnoreCase);
var series = auction.SnapshotPriceSeries();
var firstSeen = auction.FirstSeenAt ?? auction.AddedAt;
var observedMinutes = Math.Max(0, (summary.EndedAt - firstSeen).TotalMinutes);
return new AuctionDetailRecord
{
AuctionId = summary.AuctionId,
Name = summary.Name,
ProductKey = summary.ProductKey,
Url = summary.OriginalUrl,
Outcome = summary.Outcome,
WonByMe = summary.WonByMe,
Winner = summary.Winner,
FinalPrice = summary.FinalPrice,
BuyNowPrice = auction.BuyNowPrice,
ShippingCost = auction.ShippingCost,
FirstSeenAt = firstSeen,
EndedAt = summary.EndedAt,
ObservedMinutes = observedMinutes,
MyBids = summary.MyBidsUsed,
TotalObservedBids = bidsByUser.Values.Sum(),
DistinctBidders = bidsByUser.Count,
Resets = auction.ResetCount,
BidsByUser = bidsByUser,
TopBidderShare = AuctionDetailRecord.ComputeTopShare(bidsByUser),
PriceSeries = series,
PriceVelocityPerMinute = observedMinutes > 0.01 ? summary.FinalPrice / observedMinutes : 0,
AveragePingMs = auction.AverageLatencyMs,
PollCount = auction.PollCount,
PollErrors = auction.PollErrors,
ConfiguredLeadMs = auction.BidBeforeDeadlineMs,
TimerExpiredCount = auction.TimerExpiredCount,
SuccessfulBids = auction.SuccessfulBidCount,
FailedBids = auction.FailedBidCount,
BidCostEuro = auction.BidCost,
ObservedFromStart = auction.ObservedFromStart,
ObservedToEnd = auction.ObservedToEnd
};
}
/// <summary>
/// Avvisa con una notifica di sistema. Una vittoria va confermata su Bidoo, e
/// accorgersene solo riaprendo la finestra è facile da dimenticare.
/// </summary>
private static void NotifyOutcome(CompletedAuctionRecord record, AppSettings settings)
{
try
{
if (record.WonByMe && settings.NotifyOnWin)
{
WindowsNotifier.Show(
"Asta vinta!",
$"{record.Name}\nAggiudicata a € {record.FinalPrice:F2} con {record.MyBidsUsed} puntate.\n" +
"Ricordati di completare l'acquisto su Bidoo.");
}
else if (!record.WonByMe && settings.NotifyOnLoss && record.MyBidsUsed > 0)
{
// Solo se ci avevamo davvero puntato: le aste solo osservate non
// meritano un avviso.
WindowsNotifier.Show(
"Asta persa",
$"{record.Name}\nChiusa a € {record.FinalPrice:F2} da {record.Winner}.",
warning: true);
}
}
catch { }
}
}
}
@@ -0,0 +1,26 @@
using System;
using System.Windows;
using System.Windows.Input;
using Microsoft.Web.WebView2.Core;
namespace AutoBidder
{
/// <summary>
/// Stub event handlers for XAML binding - actual implementations are in dedicated partial files
/// </summary>
public partial class MainWindow
{
// These methods exist only to satisfy XAML event bindings
// The actual implementations are in the appropriate partial class files:
// - MainWindow.ButtonHandlers.cs for button clicks
// - MainWindow.EventHandlers.Browser.cs for browser events
// - MainWindow.EventHandlers.Export.cs for export events
// - MainWindow.EventHandlers.Settings.cs for settings events
// - MainWindow.EventHandlers.Stats.cs for statistics events
private void MultiAuctionsGrid_PreviewKeyDown(object sender, KeyEventArgs e)
{
// Optional: Add keyboard shortcuts for grid navigation
}
}
}
@@ -0,0 +1,669 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.ViewModels;
using AutoBidder.Utilities;
using AutoBidder.Services; // HtmlCacheService, HtmlResponse
using AutoBidder.Net; // RequestPriority
namespace AutoBidder
{
/// <summary>
/// Auction management: Add, Remove, Update
/// </summary>
public partial class MainWindow
{
private async Task AddAuctionById(string input)
{
try
{
if (string.IsNullOrWhiteSpace(input))
{
MessageBox.Show("Input non valido!", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
string? auctionId;
string? productName = null;
string originalUrl;
// Verifica se un URL o solo un ID
if (input.Contains("bidoo.com") || input.Contains("http"))
{
// un URL - estrai ID e nome prodotto dall'URL stesso
originalUrl = input.Trim();
auctionId = ExtractAuctionId(originalUrl);
if (string.IsNullOrEmpty(auctionId))
{
MessageBox.Show("Impossibile estrarre ID dall'URL!", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
productName = ExtractProductName(originalUrl);
}
else
{
// solo un ID numerico - costruisci URL generico
auctionId = input.Trim();
originalUrl = $"https://it.bidoo.com/auction.php?a=asta_{auctionId}";
}
// Verifica duplicati
if (_auctionViewModels.Any(a => a.AuctionId == auctionId))
{
MessageBox.Show("Asta gi monitorata!", "Duplicato", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// ? MODIFICATO: Nome senza ID (gi nella colonna separata)
var displayName = string.IsNullOrEmpty(productName)
? $"Asta {auctionId}"
: DecodeAllHtmlEntities(productName);
// CARICA IMPOSTAZIONI PREDEFINITE SALVATE
var settings = Utilities.SettingsManager.Load();
// ? Determina stato iniziale dalla configurazione
bool isActive = false;
bool isPaused = false;
switch (settings.DefaultNewAuctionState)
{
case "Active":
isActive = true;
isPaused = false;
break;
case "Paused":
isActive = true;
isPaused = true;
break;
case "Stopped":
default:
isActive = false;
isPaused = false;
break;
}
// Crea model con valori dalle impostazioni salvate e stato configurato
var auction = new AuctionInfo
{
AuctionId = auctionId,
Name = DecodeAllHtmlEntities(displayName),
OriginalUrl = originalUrl,
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
IsActive = isActive,
IsPaused = isPaused
};
// Aggiungi al monitor
_auctionMonitor.AddAuction(auction);
// Limiti del prodotto se ne ha di suoi, altrimenti i predefiniti. Lo stato
// non si tocca: qui l'ha scelto l'utente nella finestra di aggiunta, mentre
// lo stato del prodotto vale per le aste che entrano da sole.
var limits = ProductRuleResolver.ResolveByName(auction.Name, settings);
var vm = new AuctionViewModel(auction)
{
MinPrice = limits.MinPrice,
MaxPrice = limits.MaxPrice,
MaxClicks = limits.MaxClicks
};
_auctionViewModels.Add(vm);
// ? Auto-start del monitoraggio se l'asta attiva e il monitoraggio fermo
if (isActive && !_isAutomationActive)
{
_auctionMonitor.Start();
_isAutomationActive = true;
Log($"[AUTO-START] Monitoraggio avviato automaticamente per nuova asta: {vm.Name}", LogLevel.Info);
}
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
var stateText = isActive ? (isPaused ? "Paused" : "Active") : "Stopped";
Log($"[ADD] Asta aggiunta con stato={stateText}, Anticipo={settings.DefaultBidBeforeDeadlineMs}ms", Utilities.LogLevel.Info);
// ? NUOVO: Se il nome non stato estratto, recuperalo in background DOPO l'aggiunta
if (string.IsNullOrEmpty(productName))
{
_ = FetchAuctionNameInBackgroundAsync(auction, vm);
}
}
catch (Exception ex)
{
Log($"Errore aggiunta asta: {ex.Message}");
MessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// Recupera il nome dell'asta in background e aggiorna l'UI quando completa
/// </summary>
private async Task FetchAuctionNameInBackgroundAsync(AuctionInfo auction, AuctionViewModel vm)
{
try
{
// ? USA IL SERVIZIO CENTRALIZZATO invece di HttpClient diretto
var response = await _htmlCacheService.GetHtmlAsync(
auction.OriginalUrl,
RequestPriority.Normal,
bypassCache: false // Usa cache se disponibile
);
if (!response.Success)
{
Log($"[WARN] Impossibile recuperare nome per asta {auction.AuctionId}: {response.Error}", LogLevel.Warning);
return;
}
// Estrai nome dal <title>
var match = System.Text.RegularExpressions.Regex.Match(response.Html, @"<title>([^<]+)</title>");
if (match.Success)
{
var productName = match.Groups[1].Value.Trim().Replace(" - Bidoo", "");
// ? Decodifica entity HTML (incluse quelle non standard)
productName = DecodeAllHtmlEntities(productName);
// ? MODIFICATO: Nome senza ID
var newName = productName;
// Aggiorna il nome su thread UI
Dispatcher.Invoke(() =>
{
auction.Name = newName;
// Forza refresh della griglia per mostrare il nuovo nome
var tempSource = MultiAuctionsGrid.ItemsSource;
MultiAuctionsGrid.ItemsSource = null;
MultiAuctionsGrid.ItemsSource = tempSource;
SaveAuctions(); // Salva il nome aggiornato
Log($"[NAME] Nome recuperato per asta {auction.AuctionId}: {productName}{(response.FromCache ? " (cached)" : "")}", LogLevel.Info);
});
}
else
{
Log($"[WARN] Nome non trovato nell'HTML per asta {auction.AuctionId}", LogLevel.Warning);
}
}
catch (Exception ex)
{
Log($"[WARN] Errore recupero nome per asta {auction.AuctionId}: {ex.Message}", LogLevel.Warning);
}
}
/// <summary>
/// Decodifica tutte le entity HTML, incluse quelle non standard come &plus;
/// </summary>
private string DecodeAllHtmlEntities(string text)
{
if (string.IsNullOrEmpty(text))
return text;
// Prima decodifica entity standard
var decoded = System.Net.WebUtility.HtmlDecode(text);
// ? Poi sostituisci entity non standard che WebUtility.HtmlDecode non gestisce
decoded = decoded.Replace("&plus;", "+");
decoded = decoded.Replace("&equals;", "=");
decoded = decoded.Replace("&minus;", "-");
decoded = decoded.Replace("&times;", "");
decoded = decoded.Replace("&divide;", "");
decoded = decoded.Replace("&percnt;", "%");
decoded = decoded.Replace("&dollar;", "$");
decoded = decoded.Replace("&euro;", "");
decoded = decoded.Replace("&pound;", "");
return decoded;
}
private async Task AddAuctionFromUrl(string url)
{
try
{
if (!IsValidAuctionUrl(url))
{
MessageBox.Show("URL asta non valido!", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var auctionId = ExtractAuctionId(url);
if (string.IsNullOrEmpty(auctionId))
{
MessageBox.Show("Impossibile estrarre ID asta!", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// Verifica duplicati
if (_auctionViewModels.Any(a => a.AuctionId == auctionId))
{
MessageBox.Show("Asta gi monitorata!", "Duplicato", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Fetch nome (opzionale)
var name = $"Asta {auctionId}";
try
{
// ? USA IL SERVIZIO CENTRALIZZATO
var response = await _htmlCacheService.GetHtmlAsync(url, RequestPriority.Normal);
if (response.Success)
{
var match2 = System.Text.RegularExpressions.Regex.Match(response.Html, @"<title>([^<]+)</title>");
if (match2.Success)
{
name = DecodeAllHtmlEntities(match2.Groups[1].Value.Trim().Replace(" - Bidoo", ""));
}
}
}
catch { }
// CARICA IMPOSTAZIONI PREDEFINITE SALVATE
var settings = Utilities.SettingsManager.Load();
// ? Determina stato iniziale dalla configurazione
bool isActive = false;
bool isPaused = false;
switch (settings.DefaultNewAuctionState)
{
case "Active":
isActive = true;
isPaused = false;
break;
case "Paused":
isActive = true;
isPaused = true;
break;
case "Stopped":
default:
isActive = false;
isPaused = false;
break;
}
// Crea model con valori dalle impostazioni salvate e stato configurato
var auction = new AuctionInfo
{
AuctionId = auctionId,
Name = DecodeAllHtmlEntities(name),
OriginalUrl = url,
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
IsActive = isActive,
IsPaused = isPaused
};
// Aggiungi al monitor
_auctionMonitor.AddAuction(auction);
// Limiti del prodotto se ne ha di suoi, altrimenti i predefiniti. Lo stato
// non si tocca: qui l'ha scelto l'utente nella finestra di aggiunta, mentre
// lo stato del prodotto vale per le aste che entrano da sole.
var limits = ProductRuleResolver.ResolveByName(auction.Name, settings);
var vm = new AuctionViewModel(auction)
{
MinPrice = limits.MinPrice,
MaxPrice = limits.MaxPrice,
MaxClicks = limits.MaxClicks
};
_auctionViewModels.Add(vm);
// ? Auto-start del monitoraggio se l'asta attiva e il monitoraggio fermo
if (isActive && !_isAutomationActive)
{
_auctionMonitor.Start();
_isAutomationActive = true;
Log($"[AUTO-START] Monitoraggio avviato automaticamente per nuova asta: {vm.Name}", LogLevel.Info);
}
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
var stateText = isActive ? (isPaused ? "Paused" : "Active") : "Stopped";
Log($"[ADD] Asta aggiunta con stato={stateText}, Anticipo={settings.DefaultBidBeforeDeadlineMs}ms", Utilities.LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Errore aggiunta asta: {ex.Message}");
MessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// Aggiorna manualmente il nome di un'asta recuperandolo dall'HTML
/// </summary>
public async Task RefreshAuctionNameAsync(AuctionViewModel vm)
{
if (vm == null) return;
try
{
Log($"[NAME REFRESH] Aggiornamento nome per: {vm.Name}", LogLevel.Info);
await FetchAuctionNameInBackgroundAsync(vm.AuctionInfo, vm);
}
catch (Exception ex)
{
Log($"[ERRORE] Refresh nome asta: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Controlla se ci sono aste con nomi generici e prova a recuperarli dopo un delay
/// </summary>
private async Task RetryFailedAuctionNamesAsync()
{
try
{
// Aspetta 30 secondi prima di ritentare (d tempo alle altre richieste di completare)
await System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(30));
// Trova aste con nomi generici "Asta XXXX"
var auctionsWithGenericNames = _auctionViewModels
.Where(vm => vm.Name.StartsWith("Asta ") && !vm.Name.Contains("Shop") && !vm.Name.Contains(""))
.ToList();
if (auctionsWithGenericNames.Count > 0)
{
Log($"[NAME RETRY] Trovate {auctionsWithGenericNames.Count} aste con nomi generici. Ritento recupero...", LogLevel.Info);
// Ritenta il recupero per ognuna (con delay tra una e l'altra per non sovraccaricare)
foreach (var vm in auctionsWithGenericNames)
{
await FetchAuctionNameInBackgroundAsync(vm.AuctionInfo, vm);
await System.Threading.Tasks.Task.Delay(2000); // 2 secondi tra una richiesta e l'altra
}
}
}
catch (Exception ex)
{
Log($"[WARN] Errore retry nomi aste: {ex.Message}", LogLevel.Warning);
}
}
private void SaveAuctions()
{
try
{
var auctions = _auctionMonitor.GetAuctions();
Utilities.PersistenceManager.SaveAuctions(auctions);
}
catch (Exception ex)
{
Log($"[ERRORE] Errore salvataggio: {ex.Message}");
}
}
private void LoadSavedAuctions()
{
try
{
// ? Carica impostazioni
var settings = Utilities.SettingsManager.Load();
// Ottieni username corrente dalla sessione per ripristinare IsMyBid
var session = _auctionMonitor.GetSession();
var currentUsername = session?.Username ?? string.Empty;
var auctions = Utilities.PersistenceManager.LoadAuctions();
foreach (var auction in auctions)
{
// Protezione: rimuovi eventuali BidHistory null
auction.BidHistory = auction.BidHistory?.Where(b => b != null).ToList() ?? new System.Collections.Generic.List<BidHistory>();
// ? Decode HTML entities (incluse quelle non standard)
try { auction.Name = DecodeAllHtmlEntities(auction.Name ?? string.Empty); } catch { }
// ? Ripristina IsMyBid per tutte le puntate in RecentBids
if (auction.RecentBids != null && auction.RecentBids.Count > 0 && !string.IsNullOrEmpty(currentUsername))
{
foreach (var bid in auction.RecentBids)
{
bid.IsMyBid = bid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase);
}
}
// ? NUOVO: Gestione stato in base a RememberAuctionStates
if (settings.RememberAuctionStates)
{
// MODO 1: Ripristina lo stato salvato di ogni asta (IsActive e IsPaused vengono dal file salvato)
// Non serve fare nulla, lo stato gi quello salvato nel file
}
else
{
// MODO 2: Applica DefaultStartAuctionsOnLoad a tutte le aste
var loadState = settings.DefaultStartAuctionsOnLoad;
switch (loadState)
{
case "Active":
auction.IsActive = true;
auction.IsPaused = false;
break;
case "Paused":
auction.IsActive = true;
auction.IsPaused = true;
break;
case "Stopped":
default:
auction.IsActive = false;
auction.IsPaused = false;
break;
}
}
_auctionMonitor.AddAuction(auction);
var vm = new AuctionViewModel(auction);
_auctionViewModels.Add(vm);
}
// ? Avvia monitoraggio se ci sono aste in stato Active O Paused
bool hasActiveOrPausedAuctions = auctions.Any(a => a.IsActive);
if (hasActiveOrPausedAuctions && auctions.Count > 0)
{
_auctionMonitor.Start();
_isAutomationActive = true;
if (settings.RememberAuctionStates)
{
var activeCount = auctions.Count(a => a.IsActive && !a.IsPaused);
var pausedCount = auctions.Count(a => a.IsActive && a.IsPaused);
Log($"[AUTO-START] Monitoraggio avviato: {activeCount} attive, {pausedCount} in pausa (stati ripristinati)", LogLevel.Info);
}
else
{
var loadState = settings.DefaultStartAuctionsOnLoad;
if (loadState == "Active")
{
Log($"[AUTO-START] Monitoraggio avviato automaticamente per {auctions.Count} aste caricate in stato attivo", LogLevel.Info);
}
else if (loadState == "Paused")
{
Log($"[AUTO-START] Monitoraggio avviato automaticamente per {auctions.Count} aste caricate in pausa", LogLevel.Info);
}
}
}
UpdateTotalCount();
UpdateGlobalControlButtons();
// Log sempre mostrato (anche con 0 aste)
if (auctions.Count > 0)
{
if (settings.RememberAuctionStates)
{
Log($"[LOAD] {auctions.Count} aste caricate con stati individuali ripristinati", LogLevel.Info);
}
else
{
Log($"[LOAD] {auctions.Count} aste caricate con stato iniziale: {settings.DefaultStartAuctionsOnLoad}", LogLevel.Info);
}
}
else
{
Log("[LOAD] Nessuna asta salvata", LogLevel.Info);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Caricamento aste: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Aggiorna i dettagli dell'asta selezionata nel pannello Info Prodotto
/// </summary>
private void UpdateSelectedAuctionDetails(AuctionViewModel? vm)
{
if (vm == null || vm.AuctionInfo == null)
{
// Resetta campi se nessuna asta selezionata
AuctionMonitor.ProductBuyNowPriceText.Text = "";
AuctionMonitor.ProductShippingCostText.Text = "";
AuctionMonitor.ProductWinLimitText.Text = "";
RefreshProductVerdict(null);
return;
}
var auction = vm.AuctionInfo;
// CARICA AUTOMATICAMENTE INFO PRODOTTO SE NON PRESENTI
if (!auction.BuyNowPrice.HasValue && !auction.ShippingCost.HasValue)
{
// Carica in background senza bloccare l'UI
_ = LoadProductInfoInBackgroundAsync(auction);
}
// Aggiorna i campi delle impostazioni
UpdateAuctionSettingsDisplay(vm);
// Aggiorna Valore (Compra Subito)
if (auction.BuyNowPrice.HasValue)
{
AuctionMonitor.ProductBuyNowPriceText.Text = $"{auction.BuyNowPrice.Value:F2}";
}
else
{
AuctionMonitor.ProductBuyNowPriceText.Text = "";
}
// Aggiorna Spese di Spedizione
if (auction.ShippingCost.HasValue)
{
AuctionMonitor.ProductShippingCostText.Text = $"{auction.ShippingCost.Value:F2}";
}
else
{
AuctionMonitor.ProductShippingCostText.Text = "";
}
// Aggiorna Limiti di Vincita
if (auction.HasWinLimit && !string.IsNullOrWhiteSpace(auction.WinLimitDescription))
{
AuctionMonitor.ProductWinLimitText.Text = auction.WinLimitDescription;
}
else if (!auction.HasWinLimit)
{
AuctionMonitor.ProductWinLimitText.Text = "";
}
else
{
AuctionMonitor.ProductWinLimitText.Text = "";
}
// Verdetto di convenienza: costo totale se vinci contro valore del prodotto.
RefreshProductVerdict(vm);
RefreshSelectedStats(vm);
}
/// <summary>
/// Carica le informazioni del prodotto (e nome se generico) in background quando selezioni un'asta
/// </summary>
private async System.Threading.Tasks.Task LoadProductInfoInBackgroundAsync(AuctionInfo auction)
{
try
{
bool hasGenericName = auction.Name.StartsWith("Asta ") &&
!auction.Name.Contains("Shop") &&
!auction.Name.Contains("") &&
!auction.Name.Contains("Buono") &&
!auction.Name.Contains("Carburante");
Log($"[PRODUCT INFO] Caricamento automatico per: {auction.Name}{(hasGenericName ? " (+ nome generico)" : "")}", Utilities.LogLevel.Info);
// ? USA IL SERVIZIO CENTRALIZZATO
var response = await _htmlCacheService.GetHtmlAsync(
auction.OriginalUrl,
RequestPriority.Normal, // Priorit alta per info prodotto
bypassCache: false
);
if (!response.Success)
{
Log($"[PRODUCT INFO] Errore caricamento: {response.Error}", Utilities.LogLevel.Warning);
return;
}
bool updated = false;
// 1. ? Se nome generico, estrai nome reale dal <title>
if (hasGenericName)
{
var matchTitle = System.Text.RegularExpressions.Regex.Match(response.Html, @"<title>([^<]+)</title>");
if (matchTitle.Success)
{
var productName = matchTitle.Groups[1].Value.Trim().Replace(" - Bidoo", "");
productName = DecodeAllHtmlEntities(productName);
// ? MODIFICATO: Nome senza ID
var newName = productName;
auction.Name = newName;
updated = true;
Log($"[NAME] Nome recuperato: {productName}{(response.FromCache ? " (cached)" : "")}", LogLevel.Info);
}
}
// 2. ? Estrai informazioni prodotto (prezzo, spedizione, limiti)
var extracted = Utilities.ProductValueCalculator.ExtractProductInfo(response.Html, auction);
if (extracted)
{
updated = true;
Log($"[PRODUCT INFO] Valore={auction.BuyNowPrice:F2}, Spedizione={auction.ShippingCost:F2}{(response.FromCache ? " (cached)" : "")}", Utilities.LogLevel.Success);
}
// 3. ? Salva e aggiorna UI solo se qualcosa cambiato
if (updated)
{
SaveAuctions();
Dispatcher.Invoke(() =>
{
// Refresh griglia per mostrare nome aggiornato
if (hasGenericName)
{
var tempSource = MultiAuctionsGrid.ItemsSource;
MultiAuctionsGrid.ItemsSource = null;
MultiAuctionsGrid.ItemsSource = tempSource;
}
// Refresh dettagli se ancora selezionata
if (_selectedAuction != null && _selectedAuction.AuctionId == auction.AuctionId)
{
UpdateSelectedAuctionDetails(_selectedAuction);
}
});
}
}
catch (Exception ex)
{
Log($"[PRODUCT INFO] Errore caricamento: {ex.Message}", Utilities.LogLevel.Warning);
}
}
}
}
+628
View File
@@ -0,0 +1,628 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.Dialogs;
using AutoBidder.Utilities;
using AutoBidder.ViewModels;
namespace AutoBidder
{
/// <summary>
/// Button click event handlers for auction operations
/// </summary>
public partial class MainWindow
{
// sender null = azione interna (non richiesta dall'utente): si evita di loggarla.
private void StartButton_Click(object? sender, RoutedEventArgs e)
{
try
{
if (!_isAutomationActive)
{
// Avvia il monitoraggio globale
_auctionMonitor.Start();
_isAutomationActive = true;
Log("[START] Monitoraggio avviato!", LogLevel.Success);
}
// Attiva e riprendi tutte le aste
foreach (var vm in _auctionViewModels)
{
if (!vm.IsActive)
{
vm.IsActive = true;
}
vm.IsPaused = false;
}
if (sender != null) // Solo se chiamato dall'utente, non internamente
{
Log("[START ALL] Tutte le aste avviate/riprese", LogLevel.Info);
}
// ? Salva gli stati aggiornati su disco
SaveAuctions();
UpdateGlobalControlButtons();
}
catch (Exception ex)
{
Log($"[ERRORE] Avvio: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void StopButton_Click(object? sender, RoutedEventArgs e)
{
try
{
// Ferma tutte le aste
foreach (var vm in _auctionViewModels)
{
vm.IsActive = false;
}
// Ferma il monitoraggio globale
if (_isAutomationActive)
{
_auctionMonitor.Stop();
_isAutomationActive = false;
}
// ? Salva gli stati aggiornati su disco
SaveAuctions();
UpdateGlobalControlButtons();
if (sender != null) // Solo se chiamato dall'utente
{
Log("[STOP ALL] Monitoraggio fermato e tutte le aste arrestate", LogLevel.Warning);
}
}
catch (Exception ex)
{
Log($"[ERRORE STOP] {ex.Message}", LogLevel.Error);
}
}
private async void PauseAllButton_Click(object? sender, RoutedEventArgs e)
{
try
{
// Osserva vale anche per le aste ferme: servono comunque i motori accesi
// per seguirle, semplicemente non punteranno.
foreach (var vm in _auctionViewModels)
{
vm.IsActive = true;
vm.IsPaused = true;
}
if (_auctionViewModels.Count > 0 && !_isAutomationActive)
{
_auctionMonitor.Start();
_isAutomationActive = true;
}
// ? Salva gli stati aggiornati su disco
SaveAuctions();
UpdateGlobalControlButtons();
if (sender != null) // Solo se chiamato dall'utente
{
Log("[OSSERVA] Tutte le aste seguite senza puntare", LogLevel.Warning);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Pausa globale: {ex.Message}", LogLevel.Error);
}
}
private async void AddUrlButton_Click(object sender, RoutedEventArgs e)
{
var dialog = new AddAuctionSimpleDialog();
if (dialog.ShowDialog() == true)
{
var raw = dialog.AuctionId ?? string.Empty;
var parts = Regex.Split(raw, "[\r\n;]+|\\s+")
.Select(p => p.Trim())
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToList();
if (parts.Count == 0)
{
MessageBox.Show("Nessun URL/ID valido trovato.", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var added = 0;
var skipped = new System.Collections.Generic.List<string>();
foreach (var part in parts)
{
try
{
var input = part;
string? aid = null;
if (input.Contains("bidoo.com") || input.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
aid = ExtractAuctionId(input);
}
else
{
aid = input;
}
if (!string.IsNullOrEmpty(aid) && _auctionViewModels.Any(a => a.AuctionId == aid))
{
skipped.Add(part + " (duplicato)");
continue;
}
await AddAuctionById(input);
added++;
}
catch (Exception ex)
{
skipped.Add(part + " (errore: " + ex.Message + ")");
}
}
UpdateGlobalControlButtons();
var summary = $"Aggiunte: {added}. Skipped: {skipped.Count}.";
if (skipped.Count > 0)
summary += "\nDettagli: " + string.Join("; ", skipped.Take(10));
MessageBox.Show(summary, "Aggiunta aste", MessageBoxButton.OK, MessageBoxImage.Information);
// ? RIMOSSO: Retry automatico ora avviene alla selezione on-demand
// Le aste con nome generico vengono aggiornate automaticamente quando l'utente le seleziona
}
}
private void RemoveUrlButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var auctionName = _selectedAuction.Name;
var auctionId = _selectedAuction.AuctionId;
// Salva l'indice corrente prima di rimuovere
var currentIndex = _auctionViewModels.IndexOf(_selectedAuction);
// Conferma rimozione
var result = MessageBox.Show(
$"Rimuovere l'asta dal monitoraggio?\n\n{auctionName}\n(ID: {auctionId})\n\nL'asta verr eliminata dalla lista e non sar pi monitorata.",
"Conferma Rimozione",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
{
Log($"[REMOVE] Rimozione annullata: {auctionName}", LogLevel.Info);
return;
}
try
{
// Rimuove dal monitor
_auctionMonitor.RemoveAuction(auctionId);
// Rimuove dal ViewModel
_auctionViewModels.Remove(_selectedAuction);
// Salva modifiche
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
Log($"[REMOVE] Asta rimossa: {auctionName} (ID: {auctionId})", LogLevel.Success);
// ? NUOVO: Sposta il focus sulla riga successiva
if (_auctionViewModels.Count > 0)
{
// Se c' ancora almeno un'asta nella lista
int newIndex;
if (currentIndex >= _auctionViewModels.Count)
{
// L'asta rimossa era l'ultima, seleziona la nuova ultima
newIndex = _auctionViewModels.Count - 1;
}
else
{
// Seleziona l'asta che ora si trova nella stessa posizione
newIndex = currentIndex;
}
// Seleziona l'asta
MultiAuctionsGrid.SelectedIndex = newIndex;
_selectedAuction = _auctionViewModels[newIndex];
// ? FIX: Salva il nome della NUOVA asta selezionata per il log
var newAuctionName = _selectedAuction?.Name ?? "Sconosciuta";
// Forza il focus sulla griglia dopo un breve delay per permettere alla UI di aggiornarsi
Dispatcher.BeginInvoke(new Action(() =>
{
MultiAuctionsGrid.Focus();
// Scroll fino alla riga selezionata per assicurarsi che sia visibile
if (MultiAuctionsGrid.SelectedItem != null)
{
MultiAuctionsGrid.ScrollIntoView(MultiAuctionsGrid.SelectedItem);
}
// ? FIX: Usa la variabile locale invece di _selectedAuction.Name
Log($"[FOCUS] Focus spostato su: {newAuctionName}", LogLevel.Info);
}), System.Windows.Threading.DispatcherPriority.Background);
}
else
{
// Nessuna asta rimasta, reset selezione
_selectedAuction = null;
Log($"[REMOVE] Nessuna asta rimasta nella lista", LogLevel.Info);
}
}
catch (Exception ex)
{
Log($"[ERROR] Errore rimozione asta: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante la rimozione:\n{ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void RemoveAllButton_Click(object sender, RoutedEventArgs e)
{
if (_auctionViewModels.Count == 0)
{
MessageBox.Show("Non ci sono aste da rimuovere", "Lista Vuota", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var count = _auctionViewModels.Count;
// Conferma rimozione
var result = MessageBox.Show(
$"Rimuovere TUTTE le aste dal monitoraggio?\n\nSono presenti {count} aste monitorate.\n\nTutte le aste verranno eliminate dalla lista e non saranno pi monitorate.",
"Conferma Rimozione Totale",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes)
{
Log($"[REMOVE ALL] Rimozione annullata", LogLevel.Info);
return;
}
try
{
// Ferma il monitoraggio se attivo
if (_isAutomationActive)
{
_auctionMonitor.Stop();
_isAutomationActive = false;
Log("[STOP] Monitoraggio fermato prima della rimozione totale", LogLevel.Info);
}
// Rimuove tutte le aste dal monitor e dal ViewModel
var auctionsToRemove = _auctionViewModels.ToList(); // Copia per evitare modifiche durante iterazione
foreach (var auction in auctionsToRemove)
{
_auctionMonitor.RemoveAuction(auction.AuctionId);
}
// Pulisci la lista ViewModel
_auctionViewModels.Clear();
// Resetta selezione
_selectedAuction = null;
// Salva modifiche
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
Log($"[REMOVE ALL] Tutte le aste rimosse: {count} aste eliminate", LogLevel.Success);
MessageBox.Show($"Tutte le {count} aste sono state rimosse dal monitoraggio.", "Rimozione Completata", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
Log($"[ERROR] Errore rimozione totale: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante la rimozione delle aste: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void CopyAuctionUrlButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show(
"Seleziona un'asta dalla griglia prima di copiare l'URL.",
"Nessuna Asta Selezionata",
MessageBoxButton.OK,
MessageBoxImage.Information);
Log("[INFO] Tentativo di copia URL senza asta selezionata", LogLevel.Info);
return;
}
var url = _selectedAuction.AuctionInfo.OriginalUrl;
if (string.IsNullOrEmpty(url))
url = $"https://it.bidoo.com/auction.php?a=asta_{_selectedAuction.AuctionId}";
// Tenta di copiare con retry mechanism
const int maxAttempts = 3;
const int delayMs = 50;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
Clipboard.SetText(url);
Log("URL copiato negli appunti", LogLevel.Success);
return; // Successo, esci
}
catch (System.Runtime.InteropServices.COMException ex) when (ex.ErrorCode == unchecked((int)0x800401D0)) // CLIPBRD_E_CANT_OPEN
{
if (attempt < maxAttempts)
{
// Clipboard occupato, riprova dopo un breve delay
System.Threading.Thread.Sleep(delayMs);
continue;
}
// Ultimo tentativo fallito
Log($"[WARN] Clipboard temporaneamente occupato. Il testo potrebbe essere stato copiato.", LogLevel.Warning);
return;
}
catch (Exception ex)
{
// Altri errori
Log($"[ERRORE] Impossibile copiare URL: {ex.Message}", LogLevel.Error);
return;
}
}
}
private void OpenAuctionInternalButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
var url = _selectedAuction.AuctionInfo.OriginalUrl;
if (string.IsNullOrEmpty(url))
url = $"https://it.bidoo.com/auction.php?a=asta_{_selectedAuction.AuctionId}";
// Naviga alla scheda Browser, in modalita' browser (non catalogo)
TabBrowser.IsChecked = true;
Browser.ShowBrowser();
// Naviga all'URL
if (EmbeddedWebView?.CoreWebView2 != null)
{
EmbeddedWebView.CoreWebView2.Navigate(url);
Log($"[BROWSER] Apertura asta nel browser interno: {_selectedAuction.Name}", LogLevel.Info);
}
else
{
Log($"[WARN] Browser interno non ancora inizializzato", LogLevel.Warning);
MessageBox.Show("Il browser interno non ancora pronto.\nRiprova tra qualche secondo.", "Browser", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Apertura nel browser interno: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante l'apertura: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OpenAuctionExternalButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
var url = _selectedAuction.AuctionInfo.OriginalUrl;
if (string.IsNullOrEmpty(url))
url = $"https://it.bidoo.com/auction.php?a=asta_{_selectedAuction.AuctionId}";
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
Log($"[BROWSER] Apertura asta nel browser esterno: {_selectedAuction.Name}", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Apertura nel browser esterno: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante l'apertura: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e)
{
try
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var auction = _selectedAuction.AuctionInfo;
// Verifica che ci siano le info prodotto caricate
if (!auction.BuyNowPrice.HasValue)
{
MessageBox.Show(
"Informazioni prodotto non disponibili.\n\n" +
"Il sistema le sta caricando automaticamente.\n" +
"Riprova tra qualche secondo.",
"Info Prodotto Mancanti",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
// Feedback visivo
AuctionMonitor.RefreshProductInfoButton.IsEnabled = false;
// CALCOLA LIMITI SUGGERITI (CONSERVATIVI)
double buyNowPrice = auction.BuyNowPrice.Value;
double shippingCost = auction.ShippingCost ?? 0;
double totalValue = buyNowPrice + shippingCost;
// Max EUR = 40% del valore TOTALE (pi conservativo del 50%)
double suggestedMaxPrice = totalValue * 0.40;
suggestedMaxPrice = Math.Round(suggestedMaxPrice, 2);
// CALCOLA MAX CLICKS (numero massimo puntate conservativo)
// Formula: (Valore Totale - Max EUR) / 0.20 per puntata
// Poi riduciamo del 20% per maggiore margine di sicurezza
int maxClicksTheoretical = (int)Math.Floor((totalValue - suggestedMaxPrice) / 0.20);
int suggestedMaxClicks = (int)Math.Floor(maxClicksTheoretical * 0.80); // 80% del teorico
// Minimo 10 puntate per dare comunque una chance
if (suggestedMaxClicks < 10) suggestedMaxClicks = 10;
Log($"[LIMITI] Valore={buyNowPrice:F2} + Extra={shippingCost:F2} = Tot={totalValue:F2} ? MaxEUR={suggestedMaxPrice:F2} (40%), MaxClicks={suggestedMaxClicks}", LogLevel.Info);
// CHIEDI CONFERMA
var result = MessageBox.Show(
$"Limiti suggeriti (conservativi):\n\n" +
$"Max EUR: {suggestedMaxPrice:F2}\n" +
$"Max Clicks: {suggestedMaxClicks}\n\n" +
$"Applicare questi valori?",
"Conferma Limiti",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
{
Log($"[LIMITI] Annullato dall'utente", LogLevel.Info);
return;
}
// APPLICA I LIMITI
_selectedAuction.MaxPrice = suggestedMaxPrice;
_selectedAuction.MaxClicks = suggestedMaxClicks;
// AGGIORNA UI
UpdateAuctionSettingsDisplay(_selectedAuction);
// SALVA
SaveAuctions();
Log($"[LIMITI] Applicati: MaxEUR={suggestedMaxPrice:F2}, MaxClicks={suggestedMaxClicks}", LogLevel.Success);
}
catch (Exception ex)
{
Log($"[ERRORE] Calcolo limiti: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante il calcolo dei limiti:\n\n{ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
AuctionMonitor.RefreshProductInfoButton.IsEnabled = true;
}
}
/// <summary>
/// Sposta l'asta selezionata verso l'alto nell'elenco
/// </summary>
private void MoveUpButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
var currentIndex = _auctionViewModels.IndexOf(_selectedAuction);
if (currentIndex <= 0)
{
// Gi in cima o non trovata
Log($"[MOVE] L'asta gi in cima alla lista", LogLevel.Info);
return;
}
// Sposta l'elemento verso l'alto
_auctionViewModels.Move(currentIndex, currentIndex - 1);
// Mantieni la selezione
MultiAuctionsGrid.SelectedItem = _selectedAuction;
MultiAuctionsGrid.ScrollIntoView(_selectedAuction);
// Salva il nuovo ordine
SaveAuctions();
Log($"[MOVE UP] Asta spostata verso l'alto: {_selectedAuction.Name}", LogLevel.Success);
}
catch (Exception ex)
{
Log($"[ERRORE] Spostamento asta verso l'alto: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante lo spostamento: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// Sposta l'asta selezionata verso il basso nell'elenco
/// </summary>
private void MoveDownButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
var currentIndex = _auctionViewModels.IndexOf(_selectedAuction);
if (currentIndex < 0 || currentIndex >= _auctionViewModels.Count - 1)
{
// Gi in fondo o non trovata
Log($"[MOVE] L'asta gi in fondo alla lista", LogLevel.Info);
return;
}
// Sposta l'elemento verso il basso
_auctionViewModels.Move(currentIndex, currentIndex + 1);
// Mantieni la selezione
MultiAuctionsGrid.SelectedItem = _selectedAuction;
MultiAuctionsGrid.ScrollIntoView(_selectedAuction);
// Salva il nuovo ordine
SaveAuctions();
Log($"[MOVE DOWN] Asta spostata verso il basso: {_selectedAuction.Name}", LogLevel.Success);
}
catch (Exception ex)
{
Log($"[ERRORE] Spostamento asta verso il basso: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante lo spostamento: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
+405
View File
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Catalogo nativo della scheda Esplora.
///
/// Affianca il browser integrato senza sostituirlo: il browser resta l'unico modo per
/// fare il login (da cui il cookie viene importato da solo), questa e' la via veloce
/// per confrontare molte aste senza caricare pagine.
/// </summary>
public partial class MainWindow
{
private BidooCatalogClient? _catalogClient;
private readonly List<CatalogCategory> _catalogCategories = new();
/// <summary>Aste della categoria corrente, prima dei filtri.</summary>
private List<CatalogAuction> _catalogAuctions = new();
/// <summary>
/// Le aste effettivamente mostrate, nell'ordine in cui compaiono. È su queste che
/// lavora l'aggiornamento prezzi: aggiornare anche quelle nascoste dai filtri
/// costerebbe richieste per numeri che nessuno sta guardando.
/// </summary>
private List<CatalogAuction> _catalogVisible = new();
private CatalogCategory? _catalogCategory;
private CancellationTokenSource? _catalogCts;
private CancellationTokenSource? _catalogRefreshCts;
/// <summary>Comandi usati dai pulsanti sulle singole schede prodotto.</summary>
public RelayCommand? CatalogAddCommand { get; private set; }
public RelayCommand? CatalogOpenCommand { get; private set; }
public RelayCommand? CatalogWatchCommand { get; private set; }
public RelayCommand? CatalogConfigureCommand { get; private set; }
private BidooCatalogClient CatalogClient
{
get
{
if (_catalogClient == null)
{
_catalogClient = new BidooCatalogClient(_auctionMonitor.GetApiClient().Transport)
{
Diagnostic = msg => Dispatcher.Invoke(() => Log(msg, LogLevel.Info))
};
}
return _catalogClient;
}
}
private void InitializeCatalogCommands()
{
CatalogAddCommand = new RelayCommand(async p => await AddFromCatalogAsync(p as CatalogAuction));
CatalogOpenCommand = new RelayCommand(p => OpenCatalogAuction(p as CatalogAuction));
CatalogWatchCommand = new RelayCommand(p => ToggleWatchedProduct(p as CatalogAuction));
CatalogConfigureCommand = new RelayCommand(p => ConfigureCatalogProduct(p as CatalogAuction));
}
/// <summary>Carica l'elenco categorie la prima volta che si apre la scheda Esplora.</summary>
private async Task EnsureCatalogLoadedAsync()
{
if (_catalogCategories.Count > 0) return;
// L'interruttore riflette l'impostazione salvata.
Browser.SetAutoRefresh(SettingsManager.Load().CatalogAutoRefresh);
try
{
Browser.SetCatalogMessage("Caricamento categorie…");
var categories = await CatalogClient.GetCategoriesAsync(false, CancellationToken.None);
_catalogCategories.Clear();
_catalogCategories.AddRange(categories);
Browser.SetCategories(_catalogCategories);
var first = _catalogCategories.FirstOrDefault();
if (first != null)
{
// Prima si registra la categoria corrente, poi si spunta il pulsante:
// spuntarlo scatena CategoryChanged, e senza questo ordine partirebbero
// due caricamenti concorrenti che si annullano a vicenda.
_catalogCategory = first;
first.IsSelected = true;
await LoadCategoryAsync(first);
}
else
{
Browser.SetCatalogMessage("Nessuna categoria disponibile.");
}
}
catch (Exception ex)
{
Browser.SetCatalogMessage($"Impossibile caricare le categorie: {ex.Message}");
Log($"[CATALOGO] Errore categorie: {ex.Message}", LogLevel.Error);
}
}
private async Task LoadCategoryAsync(CatalogCategory category)
{
// Una richiesta per volta: cambiare categoria durante un caricamento deve
// annullare il precedente, non accodarne un altro.
var previous = _catalogCts;
var cts = new CancellationTokenSource();
_catalogCts = cts;
previous?.Cancel();
previous?.Dispose();
_catalogCategory = category;
StopCatalogAutoRefresh();
try
{
Browser.SetCatalogMessage($"Carico \"{category.DisplayName}\"…");
var settings = SettingsManager.Load();
var max = Math.Max(20, settings.CatalogMaxAuctions);
var auctions = await CatalogClient.GetAllAuctionsAsync(
category, max, cts.Token, settings.CatalogCacheSeconds);
if (cts.Token.IsCancellationRequested) return;
// La pagina HTML da sola darebbe sempre 0,01 € e il timer di partenza:
// un secondo giro su data.php porta prezzi e scadenze reali.
if (auctions.Count > 0)
await CatalogClient.UpdateStatesAsync(auctions, cts.Token);
if (cts.Token.IsCancellationRequested) return;
MarkWatchedProducts(auctions);
_catalogAuctions = auctions;
RebuildCatalogView();
StartCatalogAutoRefresh();
}
catch (OperationCanceledException)
{
// Sostituita da una richiesta piu' recente: nessun messaggio, e' normale.
}
catch (Exception ex)
{
Browser.SetCatalogMessage($"Errore: {ex.Message}");
Log($"[CATALOGO] {ex.Message}", LogLevel.Error);
}
finally
{
if (ReferenceEquals(_catalogCts, cts))
{
_catalogCts = null;
cts.Dispose();
}
}
}
/// <summary>
/// Segna quali schede appartengono a un prodotto gia' in elenco, distinguendo
/// "seguito" (stellina) da "solo configurato" (ingranaggio acceso).
/// </summary>
private static void MarkWatchedProducts(IEnumerable<CatalogAuction> auctions)
{
var products = WatchedProductsStore.GetAll();
foreach (var auction in auctions)
{
var rule = products.FirstOrDefault(p => p.Matches(auction));
auction.IsWatched = rule?.IsWatched == true;
auction.IsListed = rule != null;
}
}
/// <summary>
/// Ricostruisce (e riordina) l'elenco mostrato. Si chiama SOLO al caricamento o
/// quando cambiano i filtri: riordinare mentre si legge farebbe saltare le schede
/// da una posizione all'altra sotto il cursore.
/// </summary>
private void RebuildCatalogView()
{
var filter = Browser.SearchText;
var hideManual = Browser.HideManualAuctions;
var view = _catalogAuctions
.Where(a => !hideManual || !a.IsManualOnly)
.Where(a => filter.Length == 0 || a.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
// Le aste che stanno per chiudere sono quelle su cui si decide: prima.
.OrderBy(a => a.RemainingSeconds <= 0 ? int.MaxValue : a.RemainingSeconds)
.ToList();
_catalogVisible = view;
Browser.SetCatalogItems(view, _catalogAuctions.Count);
}
// ── Aggiornamento prezzi sul posto ───────────────────────────────
/// <summary>
/// Quante aste tenere aggiornate mentre si guarda la griglia. Il listato si sfoglia
/// ormai a migliaia, e <c>data.php</c> ne accetta una sessantina per chiamata: senza
/// un tetto, un catalogo grande significherebbe decine di richieste ogni due secondi
/// per aggiornare aste che chiudono fra ore.
/// </summary>
private const int CatalogRefreshBudget = 300;
private void StartCatalogAutoRefresh()
{
StopCatalogAutoRefresh();
if (!Browser.AutoRefreshEnabled || _catalogAuctions.Count == 0) return;
var cts = new CancellationTokenSource();
_catalogRefreshCts = cts;
_ = CatalogRefreshLoopAsync(cts.Token);
}
private void StopCatalogAutoRefresh()
{
var cts = _catalogRefreshCts;
_catalogRefreshCts = null;
cts?.Cancel();
cts?.Dispose();
}
/// <summary>
/// Aggiorna prezzi e timer delle aste in vista. I valori cambiano <i>sul posto</i>:
/// nessun riordino e nessuna ricostruzione della lista, altrimenti le schede
/// ballerebbero mentre le si guarda.
///
/// <para>Si aggiornano le prime <see cref="CatalogRefreshBudget"/> della griglia,
/// che essendo ordinata per scadenza sono quelle che stanno per chiudere: sono le
/// uniche i cui numeri cambiano da un momento all'altro.</para>
/// </summary>
private async Task CatalogRefreshLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (!await Wait.DelayAsync(2000, ct).ConfigureAwait(false)) return;
var visible = _catalogVisible;
if (visible.Count == 0) continue;
var snapshot = visible.Count > CatalogRefreshBudget
? visible.GetRange(0, CatalogRefreshBudget)
: visible;
try
{
await CatalogClient.UpdateStatesAsync(snapshot, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch
{
// Un aggiornamento saltato non compromette la pagina.
continue;
}
if (ct.IsCancellationRequested) return;
await Dispatcher.InvokeAsync(() =>
{
foreach (var auction in snapshot) auction.NotifyStateChanged();
});
}
}
// ── Azioni sulle schede ──────────────────────────────────────────
private async Task AddFromCatalogAsync(CatalogAuction? auction)
{
if (auction == null) return;
if (_auctionViewModels.Any(a => a.AuctionId == auction.AuctionId))
{
Log($"[CATALOGO] {auction.Name} è già nel monitor", LogLevel.Info);
return;
}
await AddAuctionById(auction.Url);
WatchedProductsStore.MarkHandled(auction.AuctionId);
Log($"[CATALOGO] Aggiunta al monitor: {auction.Name}", LogLevel.Info);
}
private void OpenCatalogAuction(CatalogAuction? auction)
{
if (auction == null) return;
try
{
// Passa alla scheda Browser: TabBrowser_Checked mostra il pannello e il
// browser integrato, poi si naviga all'asta.
TabBrowser.IsChecked = true;
Browser.ShowBrowser();
Browser.EmbeddedWebView?.CoreWebView2?.Navigate(auction.Url);
Browser.BrowserAddress.Text = auction.Url;
}
catch (Exception ex)
{
Log($"[CATALOGO] Apertura nel browser fallita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Attiva o disattiva la sorveglianza del prodotto (stellina). Da qui in poi le aste
/// nuove dello stesso articolo entrano nel monitor da sole.
/// </summary>
private void ToggleWatchedProduct(CatalogAuction? auction)
{
if (auction == null) return;
try
{
if (auction.IsWatched)
{
// Spegnere la stellina non butta via i limiti scritti a mano: la scheda
// resta fra i Prodotti, solo senza aggiunta automatica.
WatchedProductsStore.SetWatched(auction, false);
Log($"[SEGUITI] Non seguo più: {auction.Name}", LogLevel.Info);
}
else
{
WatchedProductsStore.SetWatched(auction, true);
var settings = SettingsManager.Load();
if (settings.AutoAddProductsEnabled)
{
Log($"[SEGUITI] Ora seguo: {auction.Name} — le aste nuove entreranno in stato {StateLabel(settings.AutoAddNewAuctionState)}",
LogLevel.Success);
_ = _productWatcher?.ScanNowAsync();
}
else
{
Log($"[SEGUITI] Ora seguo: {auction.Name} — l'aggiunta automatica è però disattivata in Impostazioni",
LogLevel.Warning);
}
}
// Tutte le schede dello stesso prodotto cambiano stella insieme.
MarkWatchedProducts(_catalogAuctions);
}
catch (Exception ex)
{
Log($"[SEGUITI] Errore: {ex.Message}", LogLevel.Error);
}
}
private static string StateLabel(string state) => state switch
{
"Active" => "Attiva",
"Stopped" => "Ferma",
_ => "Osserva"
};
// ── Handler degli eventi del controllo ───────────────────────────
private async void Browser_CatalogCategoryChanged(object sender, RoutedEventArgs e)
{
var selected = _catalogCategories.FirstOrDefault(c => c.IsSelected);
if (selected == null || ReferenceEquals(selected, _catalogCategory)) return;
await LoadCategoryAsync(selected);
}
private async void Browser_CatalogRefreshClicked(object sender, RoutedEventArgs e)
{
// Chi preme "Aggiorna" vuole i dati dal server, non quelli in cache.
CatalogClient.InvalidateCache();
if (_catalogCategory == null)
{
_catalogCategories.Clear();
await EnsureCatalogLoadedAsync();
return;
}
await LoadCategoryAsync(_catalogCategory);
}
private void Browser_CatalogSearchChanged(object sender, RoutedEventArgs e)
{
// Filtri cambiati: qui il riordino ci sta, è l'utente ad averlo chiesto.
RebuildCatalogView();
}
private void Browser_CatalogAutoRefreshChanged(object sender, RoutedEventArgs e)
{
var settings = SettingsManager.Load();
settings.CatalogAutoRefresh = Browser.AutoRefreshEnabled;
SettingsManager.Save(settings);
if (Browser.AutoRefreshEnabled) StartCatalogAutoRefresh();
else StopCatalogAutoRefresh();
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using AutoBidder.Utilities;
using AutoBidder.ViewModels;
namespace AutoBidder
{
/// <summary>
/// Command implementations for MainWindow
/// </summary>
public partial class MainWindow
{
private void InitializeCommands()
{
StartAllCommand = new RelayCommand(_ => ExecuteStartAll());
StopAllCommand = new RelayCommand(_ => ExecuteStopAll());
PauseAllCommand = new RelayCommand(_ => ExecutePauseAll());
GridStartCommand = new RelayCommand(param => ExecuteGridStart(param as AuctionViewModel));
GridPauseCommand = new RelayCommand(param => ExecuteGridPause(param as AuctionViewModel));
GridStopCommand = new RelayCommand(param => ExecuteGridStop(param as AuctionViewModel));
GridBidCommand = new RelayCommand(async param => await ExecuteGridBidAsync(param as AuctionViewModel));
InitializeCatalogCommands();
InitializeProductCommands();
}
private void ExecuteStartAll()
{
StartButton_Click(null, new RoutedEventArgs());
}
private void ExecuteStopAll()
{
StopButton_Click(null, new RoutedEventArgs());
}
private void ExecutePauseAll()
{
PauseAllButton_Click(null, new RoutedEventArgs());
}
private void ExecuteGridStart(AuctionViewModel? vm)
{
if (vm == null) return;
// Attiva l'asta
vm.IsActive = true;
vm.IsPaused = false;
// Se il monitoraggio globale non attivo, avvialo automaticamente
if (!_isAutomationActive)
{
_auctionMonitor.Start();
_isAutomationActive = true;
Log($"[AUTO-START] Monitoraggio avviato automaticamente per asta: {vm.Name}", LogLevel.Info);
}
else
{
Log($"[START] Asta avviata: {vm.Name}", LogLevel.Info);
}
// ? Salva gli stati aggiornati su disco
SaveAuctions();
UpdateGlobalControlButtons();
}
private void ExecuteGridPause(AuctionViewModel? vm)
{
if (vm == null) return;
// Osserva significa "segui ma non puntare": serve comunque un motore acceso,
// quindi l'asta va resa attiva anche se partiva da ferma.
vm.IsActive = true;
vm.IsPaused = true;
if (!_isAutomationActive)
{
_auctionMonitor.Start();
_isAutomationActive = true;
}
Log($"[OSSERVA] Asta seguita senza puntare: {vm.Name}", LogLevel.Info);
// ? Salva gli stati aggiornati su disco
SaveAuctions();
UpdateGlobalControlButtons();
}
private void ExecuteGridStop(AuctionViewModel? vm)
{
if (vm == null) return;
vm.IsActive = false;
// Se tutte le aste sono fermate, ferma anche il monitoraggio globale
bool hasActiveAuctions = _auctionViewModels.Any(a => a.IsActive);
if (!hasActiveAuctions && _isAutomationActive)
{
_auctionMonitor.Stop();
_isAutomationActive = false;
Log($"[AUTO-STOP] Monitoraggio fermato: nessuna asta attiva", LogLevel.Info);
}
else
{
Log($"[STOP] Asta fermata: {vm.Name}", LogLevel.Info);
}
// ? Salva gli stati aggiornati su disco
SaveAuctions();
UpdateGlobalControlButtons();
}
private async Task ExecuteGridBidAsync(AuctionViewModel? vm)
{
if (vm == null) return;
try
{
Log($"[BID] Puntata manuale richiesta su: {vm.Name}", LogLevel.Info);
var result = await _auctionMonitor.PlaceManualBidAsync(vm.AuctionInfo);
// Aggiorna dati puntate da risposta server per puntata manuale
if (result.Success)
{
if (result.RemainingBids.HasValue)
{
vm.AuctionInfo.RemainingBids = result.RemainingBids.Value;
// Aggiorna immediatamente il banner in alto
Dispatcher.Invoke(() => UpdateRemainingBidsDisplay());
}
if (result.BidsUsedOnThisAuction.HasValue)
{
vm.AuctionInfo.BidsUsedOnThisAuction = result.BidsUsedOnThisAuction.Value;
}
// Notifica aggiornamento contatori per aggiornare la UI - SUL THREAD UI
Dispatcher.Invoke(() => vm.RefreshCounters());
Log($"[OK] Puntata manuale su {vm.Name}: {result.LatencyMs}ms", LogLevel.Success);
}
else
{
Log($"[FAIL] Puntata manuale su {vm.Name}: {result.Error}", LogLevel.Error);
}
}
catch (System.Exception ex)
{
Log($"[ERRORE] Puntata manuale: {ex.Message}", LogLevel.Error);
}
}
}
}
@@ -0,0 +1,210 @@
using System;
using System.Windows;
namespace AutoBidder
{
/// <summary>
/// Event handlers per il nuovo sistema di connessione automatica
/// </summary>
public partial class MainWindow
{
/// <summary>
/// Handler per il click sul nome utente nella sidebar
/// </summary>
private void SidebarUsername_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
// Riusa la stessa logica del pulsante connessione (se fosse ancora presente)
ConnectionStatusButton_Click(sender, new RoutedEventArgs());
}
/// <summary>
/// Handler per il pulsante stato connessione nel banner
/// Se non connesso: apre tab Browser per login
/// Se connesso: mostra opzioni (disconnetti, riconnetti)
/// </summary>
private void ConnectionStatusButton_Click(object sender, RoutedEventArgs e)
{
try
{
var session = _sessionService?.GetCurrentSession();
if (session != null && !string.IsNullOrEmpty(session.Username))
{
// Gi connesso - Mostra opzioni
var result = MessageBox.Show(
this,
$"Connesso come: {session.Username}\n" +
$"Puntate residue: {session.RemainingBids}\n" +
$"Credito Shop: EUR {session.ShopCredit:F2}\n\n" +
"Vuoi disconnettere e accedere con un altro account?",
"Gestione Connessione",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
{
// Disconnetti
DisconnectSession();
}
}
else
{
// Non connesso - Apri browser per login
MessageBox.Show(
this,
"Per accedere:\n\n" +
"1. Fai login su Bidoo nella scheda Browser\n" +
"2. La connessione sar automatica\n\n" +
"Apertura scheda Browser...",
"Accedi a Bidoo",
MessageBoxButton.OK,
MessageBoxImage.Information);
// Apri tab Browser, in modalita' browser: il login passa da li'
TabBrowser.IsChecked = true;
Browser.ShowBrowser();
}
}
catch (Exception ex)
{
Log($"[ERRORE] Gestione connessione: {ex.Message}", Utilities.LogLevel.Error);
}
}
// ===== SEZIONE SESSIONE NELLE IMPOSTAZIONI =====
/// <summary>
/// Aggiorna il riquadro di stato sessione nelle Impostazioni.
/// </summary>
private void RefreshSettingsSessionStatus()
{
try
{
var session = _sessionService?.GetCurrentSession();
bool connected = session != null && !string.IsNullOrEmpty(session.Username);
Settings.SetSessionStatus(
connected,
session?.Username,
session?.RemainingBids ?? 0,
session?.ShopCredit ?? 0);
}
catch { }
}
/// <summary>
/// "Verifica connessione": ricontrolla contro il sito la sessione già in uso.
///
/// <para>Non chiede più un cookie da incollare: l'accesso si fa una volta dal
/// browser integrato e vale per tutta l'applicazione. Questo pulsante serve a
/// rispondere alla domanda che ci si fa davvero — "sono ancora connesso?" — che
/// prima si poteva solo dedurre da un'asta che smetteva di funzionare.</para>
/// </summary>
private async void Settings_ConnectSessionClicked(object sender, RoutedEventArgs e)
{
var cookie = _auctionMonitor.GetApiClient().Transport.Cookie;
if (string.IsNullOrWhiteSpace(cookie))
{
MessageBox.Show(this,
"Nessuna sessione da verificare.\n\n" +
"Accedi a Bidoo dalla scheda Browser: il cookie viene rilevato e importato da solo.",
"Non connesso", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
Log("[SESSION] Verifica della sessione in corso...", Utilities.LogLevel.Info);
var result = await _sessionService.ValidateAndActivateSessionAsync(cookie);
if (result.Success && result.Session != null)
{
_sessionService.SaveSession(result.Session);
SetUserBanner(result.Session.Username, result.Session.RemainingBids);
RefreshSettingsSessionStatus();
Log($"[SESSION] Connesso come {result.Session.Username} " +
$"({result.Session.RemainingBids} puntate)", Utilities.LogLevel.Success);
MessageBox.Show(this,
$"Connesso come: {result.Session.Username}\n" +
$"Puntate residue: {result.Session.RemainingBids}\n" +
$"Credito Shop: EUR {result.Session.ShopCredit:F2}",
"Sessione attiva", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
Log($"[SESSION] Sessione non più valida: {result.ErrorMessage}", Utilities.LogLevel.Error);
RefreshSettingsSessionStatus();
MessageBox.Show(this,
"La sessione non è più valida.\n\n" +
(result.ErrorMessage ?? "Cookie scaduto.") +
"\n\nRiaccedi a Bidoo dalla scheda Browser.",
"Sessione scaduta", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Verifica sessione: {ex.Message}", Utilities.LogLevel.Error);
MessageBox.Show(this, "Errore durante la verifica: " + ex.Message,
"Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>"Disconnetti" dalla sezione Impostazioni.</summary>
private void Settings_DisconnectSessionClicked(object sender, RoutedEventArgs e)
{
DisconnectSession();
RefreshSettingsSessionStatus();
}
/// <summary>"Apri Browser per il login": passa alla scheda Browser.</summary>
private void Settings_OpenBrowserLoginClicked(object sender, RoutedEventArgs e)
{
TabBrowser.IsChecked = true;
Browser.ShowBrowser();
}
/// <summary>
/// Disconnette la sessione corrente
/// </summary>
private void DisconnectSession()
{
try
{
Log("[SESSION] Disconnessione in corso...", Utilities.LogLevel.Info);
// Clear session tramite SessionService
_sessionService?.ClearSession();
// Aggiorna UI
SetUserBanner(string.Empty, 0);
// Ferma monitoraggio se attivo
if (_isAutomationActive)
{
_auctionMonitor?.Stop();
_isAutomationActive = false;
UpdateGlobalControlButtons();
}
Log("[SESSION] Disconnesso con successo", Utilities.LogLevel.Success);
MessageBox.Show(
this,
"Disconnesso con successo.\n\n" +
"Per riconnetterti, fai login nella scheda Browser.",
"Disconnesso",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
catch (Exception ex)
{
Log($"[ERRORE] Disconnessione: {ex.Message}", Utilities.LogLevel.Error);
}
}
}
}
+372
View File
@@ -0,0 +1,372 @@
using System.Windows;
namespace AutoBidder
{
/// <summary>
/// Event handlers for UserControl events and tab navigation
/// </summary>
public partial class MainWindow
{
// ===== TAB NAVIGATION =====
private void TabAsteAttive_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(AuctionMonitor);
}
private void TabBrowser_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Browser);
Browser.ShowBrowser();
}
private async void TabCerca_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Browser);
Browser.ShowCatalog();
await EnsureCatalogLoadedAsync();
}
private void TabProdotti_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Products);
LoadProducts();
}
private void TabPuntateGratis_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(FreeBids);
RefreshFreeBidsPanel();
}
private void TabDatiStatistici_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(StatisticsPanel);
LoadStatistics();
}
private void TabApprendimento_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Learning);
Learning.Refresh();
}
private void TabImpostazioni_Checked(object sender, RoutedEventArgs e)
{
try
{
// Mostra il pannello Impostazioni
ShowPanel(Settings);
// Carica impostazioni quando si apre la tab
LoadDefaultSettings();
// Aggiorna il riquadro di stato della sezione Sessione
RefreshSettingsSessionStatus();
}
catch { }
}
private void ShowPanel(System.Windows.UIElement? panelToShow)
{
// Prevent NullReferenceException during initialization
if (AuctionMonitor == null || Browser == null || StatisticsPanel == null ||
Settings == null || FreeBids == null || Products == null ||
Learning == null)
return;
// Hide all panels
AuctionMonitor.Visibility = Visibility.Collapsed;
Browser.Visibility = Visibility.Collapsed;
Products.Visibility = Visibility.Collapsed;
FreeBids.Visibility = Visibility.Collapsed;
StatisticsPanel.Visibility = Visibility.Collapsed;
Settings.Visibility = Visibility.Collapsed;
Learning.Visibility = Visibility.Collapsed;
// Show selected panel
if (panelToShow != null)
panelToShow.Visibility = Visibility.Visible;
}
// ===== AUCTION MONITOR CONTROL EVENTS =====
private void AuctionMonitor_StartClicked(object sender, RoutedEventArgs e)
{
StartButton_Click(sender, e);
}
private void AuctionMonitor_PauseAllClicked(object sender, RoutedEventArgs e)
{
PauseAllButton_Click(sender, e);
}
private void AuctionMonitor_StopClicked(object sender, RoutedEventArgs e)
{
StopButton_Click(sender, e);
}
private void AuctionMonitor_AddUrlClicked(object sender, RoutedEventArgs e)
{
AddUrlButton_Click(sender, e);
}
private void AuctionMonitor_RemoveUrlClicked(object sender, RoutedEventArgs e)
{
RemoveUrlButton_Click(sender, e);
}
private void AuctionMonitor_RemoveAllClicked(object sender, RoutedEventArgs e)
{
RemoveAllButton_Click(sender, e);
}
private void AuctionMonitor_MoveUpClicked(object sender, RoutedEventArgs e)
{
MoveUpButton_Click(sender, e);
}
private void AuctionMonitor_MoveDownClicked(object sender, RoutedEventArgs e)
{
MoveDownButton_Click(sender, e);
}
private void AuctionMonitor_AuctionSelectionChanged(object sender, RoutedEventArgs e)
{
if (AuctionMonitor.MultiAuctionsGrid.SelectedItem is ViewModels.AuctionViewModel selected)
{
_selectedAuction = selected;
UpdateSelectedAuctionDetails(selected);
// ? NUOVO: Rileva nome generico O info prodotto mancanti e recupera automaticamente
var auction = selected.AuctionInfo;
bool hasGenericName = auction.Name.StartsWith("Asta ") &&
!auction.Name.Contains("Shop") &&
!auction.Name.Contains("") &&
!auction.Name.Contains("Buono") &&
!auction.Name.Contains("Carburante");
bool needsProductInfo = !auction.BuyNowPrice.HasValue && !auction.ShippingCost.HasValue;
// Se ha nome generico O mancano info prodotto ? recupera in background
if (hasGenericName || needsProductInfo)
{
Log($"[AUTO-FETCH] Recupero automatico per: {auction.Name} (nome generico={hasGenericName}, info mancanti={needsProductInfo})", Utilities.LogLevel.Info);
// Avvia fetch in background senza bloccare UI
_ = LoadProductInfoInBackgroundAsync(auction);
}
}
}
private void AuctionMonitor_CopyUrlClicked(object sender, RoutedEventArgs e)
{
CopyAuctionUrlButton_Click(sender, e);
}
private void AuctionMonitor_OpenAuctionInternalClicked(object sender, RoutedEventArgs e)
{
OpenAuctionInternalButton_Click(sender, e);
}
private void AuctionMonitor_OpenAuctionExternalClicked(object sender, RoutedEventArgs e)
{
OpenAuctionExternalButton_Click(sender, e);
}
private void AuctionMonitor_ResetSettingsClicked(object sender, RoutedEventArgs e)
{
ResetSettingsButton_Click(sender, e);
}
private void AuctionMonitor_ClearBiddersClicked(object sender, RoutedEventArgs e)
{
ClearBiddersButton_Click(sender, e);
}
private void AuctionMonitor_ClearLogClicked(object sender, RoutedEventArgs e)
{
ClearLogButton_Click(sender, e);
}
private void AuctionMonitor_ClearGlobalLogClicked(object sender, RoutedEventArgs e)
{
ClearLogButton_Click(sender, e); // Clear Log invece di ClearGlobalLog
}
// ===== AUCTION SETTINGS EVENTS =====
private void AuctionMonitor_RefreshProductInfoClicked(object sender, RoutedEventArgs e)
{
RefreshProductInfoButton_Click(sender, e);
}
private void AuctionMonitor_ConnectionStatusClicked(object sender, RoutedEventArgs e)
{
ConnectionStatusButton_Click(sender, e);
}
private void AuctionMonitor_BidBeforeDeadlineMsChanged(object sender, RoutedEventArgs e)
{
// Riempire i campi alla selezione di un'asta scatena i TextChanged:
// senza questa guardia ogni selezione riscriveva auctions.json quattro volte
// con gli stessi valori appena letti.
if (_isUpdatingSelection) return;
if (_selectedAuction != null && int.TryParse(AuctionMonitor.SelectedBidBeforeDeadlineMs.Text, out int ms))
{
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = ms;
_selectedAuction.AuctionInfo.BidLeadIsManual = true;
SaveAuctions();
}
}
private void AuctionMonitor_MinPriceChanged(object sender, RoutedEventArgs e)
{
// Riempire i campi alla selezione di un'asta scatena i TextChanged:
// senza questa guardia ogni selezione riscriveva auctions.json quattro volte
// con gli stessi valori appena letti.
if (_isUpdatingSelection) return;
if (_selectedAuction != null && double.TryParse(AuctionMonitor.SelectedMinPrice.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double price))
{
_selectedAuction.MinPrice = price;
SaveAuctions();
}
}
private void AuctionMonitor_MaxPriceChanged(object sender, RoutedEventArgs e)
{
// Riempire i campi alla selezione di un'asta scatena i TextChanged:
// senza questa guardia ogni selezione riscriveva auctions.json quattro volte
// con gli stessi valori appena letti.
if (_isUpdatingSelection) return;
if (_selectedAuction != null && double.TryParse(AuctionMonitor.SelectedMaxPrice.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double price))
{
_selectedAuction.MaxPrice = price;
SaveAuctions();
}
}
private void AuctionMonitor_MaxClicksChanged(object sender, RoutedEventArgs e)
{
// Riempire i campi alla selezione di un'asta scatena i TextChanged:
// senza questa guardia ogni selezione riscriveva auctions.json quattro volte
// con gli stessi valori appena letti.
if (_isUpdatingSelection) return;
if (_selectedAuction != null && int.TryParse(AuctionMonitor.SelectedMaxClicks.Text, out int clicks))
{
_selectedAuction.MaxClicks = clicks;
SaveAuctions();
}
}
/// <summary>
/// Tetto di spesa in euro per l'asta selezionata. E' il fratello di MaxClicks detto
/// in denaro: chi ragiona in euro non deve fare la divisione a mente.
/// </summary>
private void AuctionMonitor_MaxSpendChanged(object sender, RoutedEventArgs e)
{
if (_isUpdatingSelection) return;
if (_selectedAuction == null) return;
var testo = AuctionMonitor.SelectedMaxSpend.Text.Trim().Replace(',', '.');
if (double.TryParse(testo, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var spesa) && spesa >= 0)
{
_selectedAuction.AuctionInfo.MaxTotalSpendEuro = spesa;
SaveAuctions();
}
}
/// <summary>Interruttore del controllo di pareggio sull'asta selezionata.</summary>
private void AuctionMonitor_BreakEvenChanged(object sender, RoutedEventArgs e)
{
if (_isUpdatingSelection) return;
if (_selectedAuction == null) return;
_selectedAuction.AuctionInfo.StopAtBreakEven = AuctionMonitor.SelectedStopAtBreakEven.IsChecked == true;
SaveAuctions();
}
// ===== BROWSER CONTROL EVENTS =====
private void Browser_BrowserBackClicked(object sender, RoutedEventArgs e)
{
try
{
if (Browser.EmbeddedWebView?.CoreWebView2 != null && Browser.EmbeddedWebView.CoreWebView2.CanGoBack)
Browser.EmbeddedWebView.CoreWebView2.GoBack();
}
catch { }
}
private void Browser_BrowserForwardClicked(object sender, RoutedEventArgs e)
{
try
{
if (Browser.EmbeddedWebView?.CoreWebView2 != null && Browser.EmbeddedWebView.CoreWebView2.CanGoForward)
Browser.EmbeddedWebView.CoreWebView2.GoForward();
}
catch { }
}
private void Browser_BrowserRefreshClicked(object sender, RoutedEventArgs e)
{
try
{
Browser.EmbeddedWebView?.Reload();
}
catch { }
}
private void Browser_BrowserHomeClicked(object sender, RoutedEventArgs e)
{
try
{
Browser.EmbeddedWebView?.CoreWebView2?.Navigate("https://it.bidoo.com/");
Browser.BrowserAddress.Text = "https://it.bidoo.com/";
}
catch { }
}
private void Browser_BrowserGoClicked(object sender, RoutedEventArgs e)
{
try
{
var url = Browser.BrowserAddress.Text?.Trim();
if (string.IsNullOrEmpty(url)) return;
if (!url.StartsWith("http", System.StringComparison.OrdinalIgnoreCase))
url = "https://" + url;
Browser.EmbeddedWebView?.CoreWebView2?.Navigate(url);
}
catch { }
}
private void Browser_BrowserAddAuctionClicked(object sender, RoutedEventArgs e)
{
try
{
var url = Browser.BrowserAddress.Text?.Trim() ?? Browser.EmbeddedWebView?.Source?.ToString();
if (!string.IsNullOrEmpty(url))
_ = AddAuctionFromUrl(url);
}
catch { }
}
// ===== SETTINGS CONTROL EVENTS =====
private void Settings_SaveDefaultsClicked(object sender, RoutedEventArgs e)
{
SaveDefaultsButton_Click(sender, e);
}
private void Settings_CancelDefaultsClicked(object sender, RoutedEventArgs e)
{
CancelDefaultsButton_Click(sender, e);
}
}
}
+241
View File
@@ -0,0 +1,241 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// La cartella dei database (con la migrazione quando cambia) e le esportazioni dello
/// storico, che chiedono sempre dove salvare.
/// </summary>
public partial class MainWindow
{
// ── Cartella dei database ────────────────────────────────────────
/// <summary>Il campo mostra sempre il percorso in uso: selezionabile, copiabile.</summary>
private void RefreshDataFolderFields()
{
try { Settings.DatabaseFolderTextBox.Text = AppPaths.DatabaseFolder; }
catch { }
}
/// <summary>
/// Traduce quello che c'è nel campo in ciò che va salvato: vuoto = predefinito,
/// uguale al predefinito = vuoto, un percorso davvero diverso = quel percorso.
/// </summary>
private static string NormalizeFolderChoice(string? typed, string resolvedDefault, string previouslySaved)
{
var text = typed?.Trim() ?? "";
if (text.Length == 0) return "";
if (string.Equals(text, resolvedDefault, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(previouslySaved))
return "";
return text;
}
private void Settings_BrowseDatabaseFolderClicked(object sender, RoutedEventArgs e)
{
var chosen = PickFolder("Scegli la cartella dei database (non una cartella sincronizzata)", AppPaths.DatabaseFolder);
if (chosen != null) Settings.DatabaseFolderTextBox.Text = chosen;
}
private void Settings_OpenDatabaseFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.DatabaseFolder);
/// <summary>
/// Selettore di cartella. Usa quello di WinForms perché WPF non ne offre uno.
/// </summary>
private static string? PickFolder(string description, string startFrom)
{
try
{
using var dialog = new System.Windows.Forms.FolderBrowserDialog
{
Description = description,
UseDescriptionForTitle = true,
SelectedPath = Directory.Exists(startFrom) ? startFrom : "",
ShowNewFolderButton = true
};
return dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK
? dialog.SelectedPath
: null;
}
catch
{
return null;
}
}
private void OpenFolder(string path)
{
try
{
Directory.CreateDirectory(path);
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[DATI] Impossibile aprire {path}: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Dopo un salvataggio con la cartella dei database cambiata: chiede se spostare i
/// file nella nuova posizione, li sposta a database chiusi, e riavvia. Senza
/// riavvio gli archivi terrebbero in memoria i dati letti dalla posizione vecchia.
/// </summary>
private void ApplyDatabaseFolderSetting(AppSettings settings, string previousFolder)
{
var newFolder = settings.DatabaseFolder.Length > 0 ? settings.DatabaseFolder : AppPaths.DefaultDatabaseFolder;
try { newFolder = Path.GetFullPath(newFolder); } catch { }
if (string.Equals(previousFolder.TrimEnd('\\'), newFolder.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
RefreshDataFolderFields();
return;
}
var answer = MessageBox.Show(this,
$"La cartella dei database cambia da\n{previousFolder}\na\n{newFolder}\n\n" +
"Vuoi spostare i database esistenti nella nuova cartella?\n\n" +
"Sì: sposto i file e riavvio l'applicazione.\n" +
"No: la nuova cartella parte vuota (i vecchi file restano dove sono) e riavvio.\n" +
"Annulla: torno alla cartella di prima.",
"Cartella dei database", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
if (answer == MessageBoxResult.Cancel)
{
settings.DatabaseFolder = string.Equals(previousFolder, AppPaths.DefaultDatabaseFolder, StringComparison.OrdinalIgnoreCase) ? "" : previousFolder;
SettingsManager.Save(settings);
RefreshDataFolderFields();
return;
}
try
{
if (answer == MessageBoxResult.Yes)
{
// Prima si chiudono: un file SQLite aperto non si sposta, e il giornale
// WAL va con lui.
AuctionDatabase.CloseInstance();
OperationalDatabase.CloseInstance();
Directory.CreateDirectory(newFolder);
var moved = 0;
foreach (var db in new[] { AppPaths.DatabaseFile, AppPaths.OperationalDatabaseFile })
{
foreach (var f in AppPaths.DatabaseFiles(db))
{
var target = Path.Combine(newFolder, Path.GetFileName(f));
if (File.Exists(target)) File.Delete(target);
File.Move(f, target);
moved++;
}
}
Log($"[DATI] Database spostati in {newFolder} ({moved} file). Riavvio.", LogLevel.Warning);
}
else
{
Log($"[DATI] Nuova cartella dei database: {newFolder} (vuota). Riavvio.", LogLevel.Warning);
}
}
catch (Exception ex)
{
MessageBox.Show(this, $"Spostamento non riuscito: {ex.Message}\n\nLa cartella resta quella di prima.",
"Cartella dei database", MessageBoxButton.OK, MessageBoxImage.Error);
settings.DatabaseFolder = string.Equals(previousFolder, AppPaths.DefaultDatabaseFolder, StringComparison.OrdinalIgnoreCase) ? "" : previousFolder;
SettingsManager.Save(settings);
RefreshDataFolderFields();
return;
}
RestartApplication();
}
/// <summary>Riavvia l'applicazione: lo stesso eseguibile, e questa istanza si chiude.</summary>
private void RestartApplication()
{
try
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe))
Process.Start(new ProcessStartInfo { FileName = exe, UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[APP] Riavvio non riuscito: {ex.Message}. Riapri l'applicazione a mano.", LogLevel.Error);
}
Application.Current.Shutdown();
}
// ── Esportazione dello storico: chiede sempre dove ───────────────
private void ExportStatsCsvButton_Click(object sender, RoutedEventArgs e)
{
var path = AskWhereToSave("Esporta lo storico in CSV", $"storico-aste-{DateTime.Now:yyyyMMdd-HHmm}.csv", "CSV (foglio di calcolo)|*.csv");
if (path == null) return;
RunExport(StatsExporter.ExportCsv(path), "CSV");
}
private void ExportStatsJsonButton_Click(object sender, RoutedEventArgs e)
{
var path = AskWhereToSave("Esporta lo storico in JSON", $"storico-aste-{DateTime.Now:yyyyMMdd-HHmm}.json", "JSON|*.json");
if (path == null) return;
RunExport(StatsExporter.ExportJson(path), "JSON");
}
/// <summary>La finestra «Salva con nome»: nessun percorso predefinito, decide l'utente.</summary>
private string? AskWhereToSave(string title, string fileName, string filter)
{
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = title,
FileName = fileName,
Filter = filter,
OverwritePrompt = true,
AddExtension = true
};
return dialog.ShowDialog(this) == true ? dialog.FileName : null;
}
catch (Exception ex)
{
Log($"[ESPORTA] Finestra di salvataggio non disponibile: {ex.Message}", LogLevel.Error);
return null;
}
}
private void RunExport(StatsExporter.Result result, string format)
{
if (!result.Success)
{
MessageBox.Show(this, result.Error ?? "Esportazione non riuscita.",
$"Esporta {format}", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
Log($"[EXPORT] {result.Records} aste esportate in {result.Path}", LogLevel.Success);
var answer = MessageBox.Show(this,
$"Esportate {result.Records} aste in formato {format}.\n\n{result.Path}\n\n" +
"Vuoi aprire la cartella?",
$"Esporta {format}", MessageBoxButton.YesNo, MessageBoxImage.Information);
if (answer == MessageBoxResult.Yes)
{
OpenFolder(Path.GetDirectoryName(result.Path) ?? AppPaths.ConfigRoot);
}
}
}
}
+420
View File
@@ -0,0 +1,420 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Scheda "Puntate": riscatto automatico delle ricompense, contatori e registro.
///
/// <para>Il lavoro vero sta in <see cref="FreeBidsAutoClaimService"/> e
/// <see cref="BidooFreeBidsClaimer"/>: qui c'è solo il collegamento fra quei servizi e
/// l'interfaccia, compreso il passaggio sul thread dell'interfaccia — il ciclo gira su
/// un thread di lavoro e non deve toccare i controlli.</para>
///
/// <para>Resta invece manuale la parte consegnata per notifica push (OneSignal) e per
/// email: quei premi sono agganciati all'abbonamento del browser, non all'account, e
/// non esiste una chiamata da fare con il cookie di sessione.</para>
/// </summary>
public partial class MainWindow
{
private FreeBidsAutoClaimService? _freeBidsService;
private BidooFreeBidsClaimer? _freeBidsClaimer;
private BidooPromoRedeemer? _promoRedeemer;
/// <summary>Esito dell'ultima raccolta, da mostrare nella riga di stato.</summary>
private string? _lastHarvestOutcome;
/// <summary>
/// Indirizzi presi dai riferimenti configurati, non scritti qui: sono gli stessi che
/// usa il riscatto automatico, e devono restare gli stessi anche quando cambiano —
/// altrimenti «Apri su Bidoo» porterebbe a una pagina diversa da quella che
/// l'applicazione sta davvero interrogando.
/// </summary>
private static FreeBidsSiteConfig FreeBidsConfig => FreeBidsConfigStore.Current;
private static string ChestsUrl => FreeBidsConfig.Url(FreeBidsConfig.Endpoints.Rewards);
private void StartFreeBidsService()
{
try
{
// Alla prima esecuzione il file dei riferimenti non c'è: crearlo subito è il
// modo per farne sapere l'esistenza a chi dovrà correggerlo.
if (FreeBidsConfigStore.EnsureFileExists())
Log($"[PUNTATE] Riferimenti del riscatto creati in {FreeBidsConfigStore.FilePath}", LogLevel.Info);
// Il file va letto davvero prima di poter dire se ha qualcosa che non va:
// un problema non ancora incontrato non è un problema che si può segnalare.
FreeBidsConfigStore.Reload();
if (FreeBidsConfigStore.LastProblem is { } problem)
Log($"[PUNTATE] {problem}", LogLevel.Warning);
var transport = _auctionMonitor.GetApiClient().Transport;
_freeBidsClaimer = new BidooFreeBidsClaimer(transport)
{
Diagnostic = msg => Dispatcher.BeginInvoke(() => Log($"[PUNTATE] {msg}", LogLevel.Info))
};
_promoRedeemer = new BidooPromoRedeemer(transport)
{
Diagnostic = msg => Dispatcher.BeginInvoke(() =>
{
Log($"[PUNTATE] {msg}", LogLevel.Info);
FreeBids.AppendActivity(msg);
})
};
_freeBidsService = new FreeBidsAutoClaimService(_freeBidsClaimer, ReadBalanceAsync, _promoRedeemer);
_freeBidsService.OnLog += (message, isProblem) => Dispatcher.BeginInvoke(() =>
{
Log($"[PUNTATE] {message}", isProblem ? LogLevel.Warning : LogLevel.Success);
FreeBids.AppendActivity(message, isProblem);
});
_freeBidsService.OnCycleCompleted += _ => Dispatcher.BeginInvoke(() =>
{
if (_freeBidsService?.LastHarvest is { } harvest) _lastHarvestOutcome = harvest.Message;
RefreshFreeBidsPanel();
});
var settings = SettingsManager.Load();
FreeBids.SetOptions(settings.FreeBidsAutoClaimEnabled, settings.FreeBidsCheckMinutes);
if (settings.FreeBidsAutoClaimEnabled) _freeBidsService.Start();
RefreshFreeBidsPanel();
}
catch (Exception ex)
{
// Il riscatto è un accessorio: se non parte, il resto dell'applicazione
// deve continuare a funzionare senza accorgersene.
Log($"[PUNTATE] Riscatto automatico non avviato: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Rilegge il saldo dal sito. Serve al servizio per misurare quante puntate ha
/// portato davvero un riscatto, invece di fidarsi del messaggio di risposta.
/// </summary>
private async Task<int?> ReadBalanceAsync(CancellationToken ct)
{
await _auctionMonitor.UpdateUserInfoAsync().ConfigureAwait(false);
return _auctionMonitor.GetSession()?.RemainingBids;
}
private void RefreshFreeBidsPanel()
{
try
{
var session = _auctionMonitor.GetSession();
FreeBids.SetBalance(session?.RemainingBids);
FreeBids.SetSchedule(
running: _freeBidsService?.IsRunning == true,
lastCheck: _freeBidsService?.LastCheckAt,
nextCheck: _freeBidsService?.NextCheckAt);
FreeBids.SetCounters(FreeBidsStats.Read());
FreeBids.SetConfigPath(FreeBidsConfigStore.FilePath, FreeBidsConfigStore.LastProblem);
FreeBids.SetHarvestStatus(
FreeBidsConfig.PromoHarvest.SourceUrl,
ClaimedPromoStore.ClaimedCount,
_lastHarvestOutcome);
}
catch { }
}
// ── Eventi della scheda ──────────────────────────────────────────
private async void FreeBids_RefreshBalanceClicked(object sender, RoutedEventArgs e)
{
try
{
await _auctionMonitor.UpdateUserInfoAsync();
RefreshFreeBidsPanel();
UpdateRemainingBidsDisplay();
Log("[PUNTATE] Saldo aggiornato", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[PUNTATE] Aggiornamento saldo non riuscito: {ex.Message}", LogLevel.Error);
}
}
private async void FreeBids_CheckNowClicked(object sender, RoutedEventArgs e)
{
if (_freeBidsService == null) return;
try
{
FreeBids.AppendActivity("Controllo richiesto a mano…");
var result = await _freeBidsService.CheckNowAsync();
if (result.IsSuccess && result.ClaimedCount == 0)
FreeBids.AppendActivity(result.Message);
RefreshFreeBidsPanel();
UpdateRemainingBidsDisplay();
}
catch (Exception ex)
{
Log($"[PUNTATE] Controllo non riuscito: {ex.Message}", LogLevel.Error);
FreeBids.AppendActivity($"controllo non riuscito: {ex.Message}", isProblem: true);
}
}
/// <summary>Apre la pagina nel browser predefinito di Windows, fuori dall'applicazione.</summary>
private void FreeBids_OpenExternalClicked(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo(ChestsUrl) { UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[PUNTATE] Apertura nel browser esterno non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void FreeBids_OpenInternalClicked(object sender, RoutedEventArgs e)
=> OpenInEmbeddedBrowser(ChestsUrl);
private void FreeBids_OpenVouchersClicked(object sender, RoutedEventArgs e)
=> OpenInEmbeddedBrowser(FreeBidsConfig.Url(FreeBidsConfig.Endpoints.Vouchers));
private void FreeBids_OpenBuyBidsClicked(object sender, RoutedEventArgs e)
=> OpenInEmbeddedBrowser(FreeBidsConfig.Url(FreeBidsConfig.Endpoints.BuyBids));
/// <summary>
/// Riscatta il codice o il collegamento incollato dall'utente.
///
/// <para>Il saldo viene riletto in ogni caso: il messaggio del sito dice quello che
/// vuole, la differenza di puntate sul conto no.</para>
/// </summary>
private async void FreeBids_ClaimPromoClicked(object sender, RoutedEventArgs e)
{
if (_freeBidsClaimer == null) return;
var code = FreeBids.PromoCode;
if (code.Length == 0)
{
FreeBids.AppendActivity("manca il codice o il collegamento da riscattare", isProblem: true);
return;
}
try
{
FreeBids.AppendActivity("Riscatto del codice richiesto a mano…");
var before = _auctionMonitor.GetSession()?.RemainingBids;
var result = await _freeBidsClaimer.ClaimPromoAsync(code);
if (!result.IsSuccess)
{
FreeBids.AppendActivity(result.Message, isProblem: true);
Log($"[PUNTATE] Riscatto codice non riuscito: {result.Message}", LogLevel.Warning);
return;
}
await _auctionMonitor.UpdateUserInfoAsync();
var after = _auctionMonitor.GetSession()?.RemainingBids;
var gained = before.HasValue && after.HasValue ? Math.Max(0, after.Value - before.Value) : 0;
if (gained > 0) FreeBidsStats.RecordClaim(claimedCount: 1, bidsGained: gained);
FreeBids.AppendActivity(gained > 0
? $"codice riscattato: +{gained} puntate"
: $"codice accettato ({result.Message}), saldo invariato");
FreeBids.ClearPromoCode();
RefreshFreeBidsPanel();
UpdateRemainingBidsDisplay();
}
catch (Exception ex)
{
Log($"[PUNTATE] Riscatto codice non riuscito: {ex.Message}", LogLevel.Error);
FreeBids.AppendActivity($"riscatto non riuscito: {ex.Message}", isProblem: true);
}
}
/// <summary>
/// Esegue subito una raccolta dei collegamenti pubblicati, senza aspettare il giro.
///
/// <para>Le puntate ottenute si misurano sul saldo prima e dopo: la pagina di Bidoo
/// risponde allo stesso modo per un codice appena riscosso e per uno già usato.</para>
/// </summary>
private async void FreeBids_HarvestClicked(object sender, RoutedEventArgs e)
{
if (_promoRedeemer == null) return;
try
{
FreeBids.AppendActivity("Raccolta dei collegamenti richiesta a mano…");
var before = _auctionMonitor.GetSession()?.RemainingBids;
var report = await _promoRedeemer.HarvestAndRedeemAsync();
_lastHarvestOutcome = report.Message;
if (!report.SourceReadable)
{
FreeBids.AppendActivity(report.Message, isProblem: true);
Log($"[PUNTATE] Raccolta non riuscita: {report.Message}", LogLevel.Warning);
RefreshFreeBidsPanel();
return;
}
if (report.Claimed > 0)
{
await _auctionMonitor.UpdateUserInfoAsync();
var after = _auctionMonitor.GetSession()?.RemainingBids;
var gained = before.HasValue && after.HasValue ? Math.Max(0, after.Value - before.Value) : 0;
FreeBidsStats.RecordClaim(report.Claimed, gained);
FreeBids.AppendActivity(gained > 0
? $"{report.Message}: +{gained} puntate"
: $"{report.Message} (saldo invariato)");
UpdateRemainingBidsDisplay();
}
else
{
FreeBids.AppendActivity(report.Message);
}
RefreshFreeBidsPanel();
}
catch (Exception ex)
{
Log($"[PUNTATE] Raccolta non riuscita: {ex.Message}", LogLevel.Error);
FreeBids.AppendActivity($"raccolta non riuscita: {ex.Message}", isProblem: true);
}
}
private void FreeBids_OpenPromoSourceClicked(object sender, RoutedEventArgs e)
=> OpenInEmbeddedBrowser(FreeBidsConfig.PromoHarvest.SourceUrl);
/// <summary>
/// Azzera la memoria dei collegamenti già aperti. Serve dopo un cambio di account:
/// i codici presi da un utente non dicono nulla su quelli disponibili per un altro.
/// </summary>
private void FreeBids_ForgetPromosClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
$"Vuoi dimenticare i {ClaimedPromoStore.ClaimedCount} codici già presi?\n\n" +
"Alla raccolta successiva verranno riaperti tutti i collegamenti pubblicati, " +
"compresi quelli già usati: serve dopo un cambio di account, non nell'uso normale.",
"Puntate", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
ClaimedPromoStore.Clear();
_lastHarvestOutcome = null;
RefreshFreeBidsPanel();
Log("[PUNTATE] Memoria dei collegamenti azzerata", LogLevel.Info);
}
/// <summary>
/// Apre <c>site-config.json</c> con l'editor predefinito. Il file viene creato se
/// manca: aprire il vuoto non direbbe a nessuno quali valori si possono cambiare.
/// </summary>
private void FreeBids_OpenConfigClicked(object sender, RoutedEventArgs e)
{
try
{
FreeBidsConfigStore.EnsureFileExists();
Process.Start(new ProcessStartInfo(FreeBidsConfigStore.FilePath) { UseShellExecute = true });
FreeBids.AppendActivity(
"Riferimenti aperti: salva il file e premi «Controlla adesso», " +
"le modifiche valgono subito.");
}
catch (Exception ex)
{
Log($"[PUNTATE] Apertura dei riferimenti non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void FreeBids_ResetCountersClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Vuoi azzerare i contatori dei riscatti?\n\n" +
"Si perde il totale storico delle puntate raccolte. Le puntate sul conto non vengono toccate.",
"Puntate", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
FreeBidsStats.Clear();
RefreshFreeBidsPanel();
Log("[PUNTATE] Contatori azzerati", LogLevel.Info);
}
private void FreeBids_OptionsChanged(object sender, RoutedEventArgs e)
{
try
{
var settings = SettingsManager.Load();
settings.FreeBidsAutoClaimEnabled = FreeBids.AutoClaimEnabled;
settings.FreeBidsCheckMinutes = FreeBids.CheckMinutes;
SettingsManager.Save(settings);
if (_freeBidsService != null)
{
if (settings.FreeBidsAutoClaimEnabled && !_freeBidsService.IsRunning)
{
_freeBidsService.Start();
Log($"[PUNTATE] Riscatto automatico attivo, ogni {settings.FreeBidsCheckMinutes} minuti", LogLevel.Success);
}
else if (!settings.FreeBidsAutoClaimEnabled && _freeBidsService.IsRunning)
{
_freeBidsService.Stop();
Log("[PUNTATE] Riscatto automatico spento", LogLevel.Info);
}
}
RefreshFreeBidsPanel();
}
catch (Exception ex)
{
Log($"[PUNTATE] Impostazioni non salvate: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Apre una pagina nel browser integrato. Deve essere quello, non il browser di
/// sistema: è lì che la sessione è attiva ed è lì che vanno concesse le notifiche.
/// </summary>
private void OpenInEmbeddedBrowser(string url)
{
try
{
TabBrowser.IsChecked = true;
Browser.ShowBrowser();
Browser.EmbeddedWebView?.CoreWebView2?.Navigate(url);
Browser.BrowserAddress.Text = url;
}
catch (Exception ex)
{
Log($"[PUNTATE] Apertura pagina non riuscita: {ex.Message}", LogLevel.Error);
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Windows;
using System.Windows.Media;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Logging functionality with color-coded severity levels and configurable minimum level filtering
/// </summary>
public partial class MainWindow
{
/// <summary>
/// Scrive un messaggio nel log globale con filtraggio basato sul livello minimo configurato
/// </summary>
/// <param name="message">Messaggio da loggare</param>
/// <param name="level">Livello di severit del messaggio</param>
private void Log(string message, LogLevel level = LogLevel.Info)
{
// Il file si scrive subito, fuori dal Dispatcher e senza filtro di livello:
// il registro serve proprio quando qualcosa è andato storto, e in quel momento
// il messaggio utile è spesso uno di quelli che a video sono nascosti. La
// scrittura è accodata, quindi non costa nulla al chiamante.
TextLogService.App(LevelTag(level), message);
Dispatcher.BeginInvoke(() =>
{
try
{
// Carica impostazioni per ottenere livello minimo e limite righe
var settings = SettingsManager.Load();
// Filtra messaggi in base al livello minimo configurato
MinimumLogLevel minLevel = MinimumLogLevel.Normal; // Default
if (Enum.TryParse<MinimumLogLevel>(settings.MinLogLevel, out var parsedLevel))
{
minLevel = parsedLevel;
}
// Se il livello del messaggio maggiore del minimo configurato, ignora
if ((int)level > (int)minLevel)
{
return;
}
var timestamp = DateTime.Now.ToString("HH:mm:ss");
var logEntry = $"[{timestamp}] [{LevelTag(level)}] {message}";
// Color coding based on severity for dark theme
var color = level switch
{
LogLevel.Error => new SolidColorBrush(Color.FromRgb(232, 17, 35)), // #E81123 (Red)
LogLevel.Warning => new SolidColorBrush(Color.FromRgb(255, 191, 0)), // #FFBF00 (Yellow)
LogLevel.Success => new SolidColorBrush(Color.FromRgb(0, 216, 0)), // #00D800 (Green)
LogLevel.Info => new SolidColorBrush(Color.FromRgb(100, 180, 255)), // #64B4FF (Light Blue)
LogLevel.Debug => new SolidColorBrush(Color.FromRgb(255, 140, 255)), // #FF8CFF (Magenta)
LogLevel.Trace => new SolidColorBrush(Color.FromRgb(160, 160, 160)), // #A0A0A0 (Gray)
_ => new SolidColorBrush(Color.FromRgb(204, 204, 204)) // #CCCCCC (Light Gray)
};
var p = new System.Windows.Documents.Paragraph { Margin = new Thickness(0, 2, 0, 2) };
var r = new System.Windows.Documents.Run(logEntry) { Foreground = color };
p.Inlines.Add(r);
LogBox.Document.Blocks.Add(p);
// Mantieni solo gli ultimi N paragrafi (configurabile dalle impostazioni)
int maxLogLines = settings.MaxGlobalLogLines;
if (LogBox.Document.Blocks.Count > maxLogLines)
{
// Rimuovi i paragrafi pi vecchi (primi inseriti)
int excessCount = LogBox.Document.Blocks.Count - maxLogLines;
for (int i = 0; i < excessCount; i++)
{
if (LogBox.Document.Blocks.FirstBlock != null)
{
LogBox.Document.Blocks.Remove(LogBox.Document.Blocks.FirstBlock);
}
}
}
// Auto-scroll if near bottom
if (LogBox.VerticalOffset >= LogBox.ExtentHeight - LogBox.ViewportHeight - 40)
{
LogBox.ScrollToEnd();
}
}
catch { }
});
}
/// <summary>
/// Sigla del livello, uguale a video e su file: due formati diversi renderebbero
/// impossibile ritrovare nel file la riga che si è vista nella finestra.
/// </summary>
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Error => "ERROR",
LogLevel.Warning => "WARN",
LogLevel.Info => "INFO",
LogLevel.Success => "OK",
LogLevel.Debug => "DEBUG",
LogLevel.Trace => "TRACE",
_ => "LOG"
};
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Threading;
using AutoBidder.Models;
using AutoBidder.Utilities;
using AutoBidder.ViewModels;
namespace AutoBidder
{
/// <summary>
/// Barra strumenti del Monitor: contatori delle aste e stato del motore.
///
/// Sono informazioni che cambiano di continuo ma non a ogni singolo poll: un
/// battito fisso di un secondo le tiene aggiornate senza far ridisegnare la
/// barra decine di volte al secondo.
/// </summary>
public partial class MainWindow
{
private DispatcherTimer? _headerTimer;
private DispatcherTimer? _timerTicker;
private void StartMonitorHeaderTimer()
{
_headerTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_headerTimer.Tick += (_, _) => RefreshMonitorHeader();
_headerTimer.Start();
// ── Il conto alla rovescia scorre da solo ────────────────────────
//
// Il tempo mancante non ha bisogno di una risposta dal server per essere
// ricalcolato: la scadenza è ancorata all'orologio di Bidoo e il resto è
// sottrazione. Prima però veniva notificato solo all'arrivo di un poll, e
// nelle prove reali fra un poll e l'altro passava anche un secondo e mezzo:
// il numero restava fermo e sembrava tutto bloccato.
//
// Cinque battiti al secondo: la colonna mostra i decimi sotto il minuto,
// quindi si vede scorrere davvero, e restano un ventesimo delle notifiche
// che servirebbero per seguire il decimo esatto.
_timerTicker = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
_timerTicker.Tick += (_, _) => TickTimers();
_timerTicker.Start();
RefreshMonitorHeader();
}
private void TickTimers()
{
var vms = _auctionViewModels;
for (var i = 0; i < vms.Count; i++)
{
try { vms[i].RefreshTimeDisplay(); }
catch { /* una riga che non si aggiorna non deve fermare le altre */ }
}
}
private void RefreshMonitorHeader()
{
try
{
var vms = _auctionViewModels;
var active = 0;
var watch = 0;
var stopped = 0;
var won = 0;
var lost = 0;
foreach (var vm in vms)
{
switch (vm.StatusKind)
{
case "won": won++; break;
case "lost": lost++; break;
case "leading":
case "active": active++; break;
case "watch": watch++; break;
default: stopped++; break;
}
}
AuctionMonitor.UpdateCounters(vms.Count, active, watch, stopped, won, lost);
var clock = _auctionMonitor.Clock;
AuctionMonitor.UpdateEngineStatus(
_auctionMonitor.RequestsSent, clock.IsSynced, clock.SampleCount, clock.AverageLatencyMs);
RefreshAccountPills();
// Lo stato dei pulsanti globali dipende da tutte le aste: ricalcolarlo a
// ogni risposta di ogni asta era uno scorrimento completo dell'elenco
// decine di volte al secondo. Una volta al secondo è più che sufficiente.
UpdateGlobalControlButtons();
if (_selectedAuction != null) RefreshSelectedStats(_selectedAuction);
}
catch { /* la barra informativa non deve mai disturbare il resto */ }
}
/// <summary>
/// Puntate, credito e vincite da confermare, riletti dalla sessione viva a ogni
/// battito.
///
/// <para>Prima ciascuno di questi numeri veniva scritto da chi lo aggiornava —
/// il ripristino della sessione, il timer di rinfresco, il codice dopo la puntata —
/// e bastava che uno dei tre non passasse perché la barra restasse su un valore
/// vecchio. Rileggerli qui costa una lettura di campo al secondo e toglie di mezzo
/// tutta quella categoria di errori.</para>
/// </summary>
private void RefreshAccountPills()
{
var session = _auctionMonitor.GetSession();
var connected = session != null && !string.IsNullOrEmpty(session.Username);
AuctionMonitor.UpdateAccountStatus(
connected ? session!.RemainingBids : null,
connected ? (decimal)session!.ShopCredit : null,
AuctionsToConfirm);
UpdateMinBidsIndicator(SettingsManager.Load().MinimumRemainingBids);
}
/// <summary>Contatori del motore relativi alla sola asta selezionata.</summary>
private void RefreshSelectedStats(AuctionViewModel vm)
{
try
{
var info = vm.AuctionInfo;
AuctionMonitor.UpdateSelectedStats(
info.ResetCount,
info.BidsUsedOnThisAuction ?? vm.MyClicks,
info.AverageLatencyMs,
info.PollCount,
info.PollErrors);
}
catch { }
}
/// <summary>
/// Scheda Prodotto: costo totale se si vince e verdetto di convenienza.
/// </summary>
private void RefreshProductVerdict(AuctionViewModel? vm)
{
try
{
var totalBox = AuctionMonitor.FindName("ProductTotalCostText") as System.Windows.Controls.TextBlock;
var value = vm?.AuctionInfo.CalculatedValue;
if (value == null)
{
if (totalBox != null) totalBox.Text = "—";
AuctionMonitor.SetVerdict("Convenienza", "non calcolabile", "neutral");
return;
}
if (totalBox != null) totalBox.Text = $"{value.TotalCostIfWin:F2} €";
if (value.Savings is not { } savings)
{
AuctionMonitor.SetVerdict("Convenienza", "valore prodotto ignoto", "neutral");
return;
}
var pct = Math.Abs(value.SavingsPercentage ?? 0);
AuctionMonitor.SetVerdict(
savings > 0 ? "Risparmio" : "Sovrapprezzo",
$"{Math.Abs(savings):F2} € ({pct:F1}%)",
savings > 0 ? "ok" : "danger");
}
catch { }
}
/// <summary>
/// "Rimuovi le aste concluse": ripulisce l'elenco da ciò che non serve più
/// senza toccare quelle ancora in corso.
/// </summary>
private void AuctionMonitor_RemoveFinishedClicked(object sender, RoutedEventArgs e)
{
try
{
var finished = _auctionViewModels
.Where(a => a.StatusKind is "won" or "lost")
.ToList();
if (finished.Count == 0)
{
MessageBox.Show(this, "Nessuna asta conclusa da rimuovere.", "Aste concluse",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
foreach (var vm in finished)
{
_auctionMonitor.RemoveAuction(vm.AuctionId);
_auctionViewModels.Remove(vm);
}
if (_selectedAuction != null && !_auctionViewModels.Contains(_selectedAuction))
_selectedAuction = null;
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
RefreshMonitorHeader();
Log($"[REMOVE] Rimosse {finished.Count} aste concluse", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Rimozione aste concluse: {ex.Message}", LogLevel.Error);
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
using AutoBidder.ViewModels;
namespace AutoBidder
{
/// <summary>
/// Prodotti seguiti: le aste nuove dello stesso articolo entrano nel monitor da sole.
///
/// L'aggiunta qui deve essere silenziosa — succede mentre l'utente sta facendo altro,
/// quindi niente finestre di dialogo: solo righe di log.
/// </summary>
public partial class MainWindow
{
private ProductWatchService? _productWatcher;
/// <summary>Aste presenti nel monitor perché aggiunte automaticamente.</summary>
private readonly HashSet<string> _autoAddedAuctionIds = new(StringComparer.Ordinal);
private void StartProductWatcher()
{
try
{
_productWatcher = new ProductWatchService(CatalogClient)
{
CountAutoAdded = () => _autoAddedAuctionIds.Count(id =>
_auctionViewModels.Any(a => a.AuctionId == id))
};
_productWatcher.OnLog += msg => Dispatcher.BeginInvoke(() => Log(msg, LogLevel.Info));
_productWatcher.OnAuctionFound += (auction, product) =>
Dispatcher.BeginInvoke(() => AutoAddAuction(auction, product));
_productWatcher.Start();
}
catch (Exception ex)
{
Log($"[SEGUITI] Impossibile avviare la sorveglianza: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Aggiunge al monitor un'asta trovata dalla sorveglianza. Volutamente separata da
/// AddAuctionById: quella mostra finestre di dialogo, qui inaccettabili.
/// </summary>
private void AutoAddAuction(CatalogAuction auction, WatchedProduct product)
{
try
{
// Marcala comunque: anche se la scartiamo, non va riproposta a ogni giro.
WatchedProductsStore.MarkHandled(auction.AuctionId);
if (_auctionViewModels.Any(a => a.AuctionId == auction.AuctionId)) return;
var settings = SettingsManager.Load();
// I limiti del prodotto, dove ci sono, hanno la precedenza sui predefiniti:
// è il senso della scheda Prodotti.
var limits = ProductRuleResolver.Resolve(product, settings);
var info = new AuctionInfo
{
AuctionId = auction.AuctionId,
Name = DecodeAllHtmlEntities(auction.Name),
OriginalUrl = auction.Url,
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
BuyNowPrice = auction.BuyNowPrice.HasValue ? (double)auction.BuyNowPrice.Value : null,
State = limits.State
};
_auctionMonitor.AddAuction(info);
var vm = new AuctionViewModel(info)
{
MinPrice = limits.MinPrice,
MaxPrice = limits.MaxPrice,
MaxClicks = limits.MaxClicks
};
_auctionViewModels.Add(vm);
_autoAddedAuctionIds.Add(auction.AuctionId);
// Un'asta che entra in Osserva o Attiva ha bisogno del motore acceso.
if (info.State != RunState.Stopped && !_isAutomationActive)
{
_auctionMonitor.Start();
_isAutomationActive = true;
}
WatchedProductsStore.CountAutoAdd(product);
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
RefreshMonitorHeader();
var origine = limits.FromProduct ? " — limiti del prodotto" : "";
Log($"[SEGUITI] Aggiunta automaticamente: {info.Name} ({StateLabel(product.AutoAddState ?? settings.AutoAddNewAuctionState)}){origine}",
LogLevel.Success);
}
catch (Exception ex)
{
Log($"[SEGUITI] Aggiunta automatica non riuscita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>Applica a caldo le impostazioni della sorveglianza dopo un salvataggio.</summary>
private void ApplyProductWatchSettings(AppSettings settings)
{
if (_productWatcher == null) return;
if (settings.AutoAddProductsEnabled && !_productWatcher.IsRunning) _productWatcher.Start();
else if (!settings.AutoAddProductsEnabled && _productWatcher.IsRunning) _productWatcher.Stop();
}
// L'elenco dei prodotti, con stellina e limiti, vive ora nella scheda Prodotti:
// vedi MainWindow.Products.cs. Qui restano solo l'avvio della sorveglianza e
// l'aggiunta automatica delle aste che trova.
}
}
+496
View File
@@ -0,0 +1,496 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.Utilities;
using AutoBidder.ViewModels;
namespace AutoBidder
{
/// <summary>
/// Scheda Prodotti: l'elenco degli articoli con la stellina e i limiti su misura.
///
/// <para>Raccoglie in un posto solo ciò che prima stava diviso fra le Impostazioni
/// (l'elenco dei prodotti seguiti) e i valori predefiniti delle aste. Ragionare per
/// prodotto è il modo naturale di usare Bidoo, che rimette all'asta lo stesso articolo
/// di continuo: i limiti giusti per un buono da 50 € non sono quelli di un telefono.</para>
/// </summary>
public partial class MainWindow
{
private readonly ObservableCollection<ProductViewModel> _productViewModels = new();
/// <summary>Stellina di una riga della scheda Prodotti.</summary>
public RelayCommand? ProductWatchCommand { get; private set; }
/// <summary>Applica i limiti consigliati alla singola riga.</summary>
public RelayCommand? ProductApplyAdviceCommand { get; private set; }
private void InitializeProductCommands()
{
ProductWatchCommand = new RelayCommand(p => ToggleProductWatch(p as ProductViewModel));
ProductApplyAdviceCommand = new RelayCommand(p => ApplyAdviceToProduct(p as ProductViewModel));
}
/// <summary>
/// Ricostruisce l'elenco unendo le schede prodotto e lo storico. Le statistiche
/// arrivano dallo stesso aggregato che alimenta la scheda Statistiche: un solo modo
/// di calcolarle, così i due pannelli non possono raccontare numeri diversi.
/// </summary>
private void LoadProducts()
{
try
{
var selectedIdentity = Products.SelectedProduct?.Identity;
var stats = CompletedAuctionsStore.AggregateByProduct()
.GroupBy(s => s.ProductKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
_productViewModels.Clear();
foreach (var rule in WatchedProductsStore.GetAll()
.OrderByDescending(r => r.IsWatched)
.ThenBy(r => r.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
stats.TryGetValue(StatsKeyFor(rule), out var summary);
_productViewModels.Add(new ProductViewModel(rule, summary));
}
Products.SetProducts(_productViewModels);
// Ricaricare non deve far perdere il posto in cui si stava lavorando.
if (selectedIdentity != null) Products.SelectProduct(selectedIdentity);
}
catch (Exception ex)
{
Log($"[PRODOTTI] Caricamento non riuscito: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Chiave con cui il prodotto compare nello storico. Le schede salvate senza chiave
/// (le più vecchie) la ricavano dal nome, com'è per le aste concluse.
/// </summary>
private static string StatsKeyFor(WatchedProduct rule) =>
string.IsNullOrEmpty(rule.ProductKey)
? ProductKeyHelper.GenerateProductKey(rule.DisplayName)
: rule.ProductKey;
private void ToggleProductWatch(ProductViewModel? product)
{
if (product == null) return;
try
{
product.IsWatched = !product.IsWatched;
WatchedProductsStore.Persist();
Products.RefreshCounters();
MarkWatchedProducts(_catalogAuctions);
Log(product.IsWatched
? $"[PRODOTTI] Ora seguo: {product.DisplayName}"
: $"[PRODOTTI] Non seguo più: {product.DisplayName} (i limiti restano)",
LogLevel.Info);
}
catch (Exception ex)
{
Log($"[PRODOTTI] {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Mette il prodotto in elenco e apre la scheda Prodotti su quella riga. È la strada
/// dell'ingranaggio sulle schede del catalogo: serve a dare limiti a un articolo
/// anche senza volerlo seguire.
/// </summary>
private void ConfigureCatalogProduct(CatalogAuction? auction)
{
if (auction == null) return;
try
{
var existing = WatchedProductsStore.FindFor(auction);
if (existing == null)
{
// Senza stellina: chi arriva da qui vuole i limiti, non l'aggiunta automatica.
WatchedProductsStore.Add(auction, watched: false);
Log($"[PRODOTTI] Aggiunto all'elenco: {auction.Name}", LogLevel.Success);
}
else
{
Log($"[PRODOTTI] {auction.Name} era già in elenco: apro la sua riga", LogLevel.Info);
}
MarkWatchedProducts(_catalogAuctions);
// Il cambio scheda ricarica già l'elenco, ma solo se la scheda cambia
// davvero: ricaricare qui rende la riga nuova visibile in ogni caso.
TabProdotti.IsChecked = true;
LoadProducts();
var identity = WatchedProductsStore.FindFor(auction)?.Identity;
if (identity != null) Products.SelectProduct(identity);
}
catch (Exception ex)
{
Log($"[PRODOTTI] Aggiunta non riuscita: {ex.Message}", LogLevel.Error);
}
}
// ── Eventi della scheda ──────────────────────────────────────────
private void Products_RefreshClicked(object sender, RoutedEventArgs e) => LoadProducts();
private void Products_ProductLimitsEdited(object sender, RoutedEventArgs e)
{
try
{
PersistProductLimits();
// Il tetto consigliato dipende dalle puntate massime: se è quella che è
// appena cambiata, la colonna accanto deve rifarsi il conto.
Products.SelectedProduct?.RefreshAdvice();
}
catch (Exception ex)
{
Log($"[PRODOTTI] Salvataggio non riuscito: {ex.Message}", LogLevel.Error);
}
}
private void Products_ProductSelectionChanged(object sender, RoutedEventArgs e)
{
// Il riquadro statistiche si aggiorna da solo: è legato alla riga selezionata.
}
private async void Products_ScanNowClicked(object sender, RoutedEventArgs e)
{
if (_productWatcher == null) return;
Log("[PRODOTTI] Cerco aste dei prodotti seguiti…", LogLevel.Info);
await _productWatcher.ScanNowAsync();
LoadProducts();
}
private void Products_RemoveClicked(object sender, RoutedEventArgs e)
{
var product = Products.SelectedProduct;
if (product == null)
{
MessageBox.Show(this, "Seleziona prima un prodotto dall'elenco.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Vuoi togliere \"{product.DisplayName}\" dall'elenco?\n\n" +
"Si perdono i limiti impostati per questo prodotto. Le aste già nel monitor restano dove sono.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
WatchedProductsStore.RemoveByIdentity(product.Identity);
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Tolto dall'elenco: {product.DisplayName}", LogLevel.Info);
}
private void Products_ClearClicked(object sender, RoutedEventArgs e)
{
if (_productViewModels.Count == 0) return;
var answer = MessageBox.Show(this,
$"Vuoi svuotare l'elenco dei {_productViewModels.Count} prodotti?\n\n" +
"Si perdono tutti i limiti impostati. Le aste già nel monitor restano dove sono.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
WatchedProductsStore.Clear();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log("[PRODOTTI] Elenco svuotato", LogLevel.Info);
}
/// <summary>
/// Rifà i numeri di ogni scheda prodotto dallo storico, con la finestra di
/// avanzamento. Vedi <see cref="ProductStatsStore.RebuildFromHistory"/>.
/// </summary>
private async void Products_RecalculateClicked(object sender, RoutedEventArgs e)
{
var aste = CompletedAuctionsStore.LoadAll().Count;
if (aste == 0)
{
MessageBox.Show(this, "Lo storico è vuoto: non c'è niente da cui ricalcolare.",
"Ricalcola dallo storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Rileggo tutto lo storico ({aste:N0} aste concluse) e ricalcolo per ogni prodotto: aste viste, " +
"prezzo minimo, massimo e medio, puntate del vincitore, vittorie e osservazioni.\n\n" +
"I limiti scritti a mano e la stellina non cambiano; cambiano i numeri, e quindi i limiti consigliati.\n\nProcedo?",
"Ricalcola dallo storico", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var esito = await Dialogs.ProgressDialog.RunAsync(this, "Ricalcolo dallo storico", "Leggo lo storico…",
(progress, ct) => System.Threading.Tasks.Task.Run(() => ProductStatsStore.RebuildFromHistory(progress, ct), ct));
if (esito.Error != null)
{
Log($"[PRODOTTI] Ricalcolo non riuscito: {esito.Error.Message}", LogLevel.Error);
return;
}
if (esito.Cancelled)
{
Log("[PRODOTTI] Ricalcolo annullato: le schede restano come prima", LogLevel.Warning);
return;
}
LoadProducts();
Log($"[PRODOTTI] Statistiche per prodotto ricalcolate dallo storico ({esito.Result:N0} aste)", LogLevel.Success);
}
private void Products_ClearAllClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Pulizia completa dei prodotti:\n\n" +
"• tolgo tutti i prodotti dall'elenco, con i loro limiti\n" +
"• azzero le statistiche per prodotto (aste concluse, prezzi tipici, consigli)\n\n" +
"Lo storico delle aste non viene toccato. Le aste già nel monitor restano dove sono.\n\nProcedo?",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
WatchedProductsStore.Clear();
ProductStatsStore.ClearStatistics();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log("[PRODOTTI] Pulizia completa: elenco e statistiche per prodotto azzerati", LogLevel.Warning);
}
private void Products_UnwatchAllClicked(object sender, RoutedEventArgs e)
{
var accese = _productViewModels.Count(p => p.IsWatched);
if (accese == 0)
{
MessageBox.Show(this, "Nessun prodotto ha la stellina accesa.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Spengo la stellina di {accese} prodotti?\n\n" +
"Nessun prodotto verrà più cercato in automatico. Restano tutti in elenco con i loro limiti.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var n = WatchedProductsStore.UnwatchAll();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Stelline spente su {n} prodotti", LogLevel.Info);
}
private void Products_RemoveUnwatchedClicked(object sender, RoutedEventArgs e)
{
var spente = _productViewModels.Count(p => !p.IsWatched);
if (spente == 0)
{
MessageBox.Show(this, "Tutti i prodotti in elenco hanno la stellina accesa: non c'è nulla da togliere.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Tolgo dall'elenco i {spente} prodotti senza stellina?\n\n" +
"Si perdono i limiti impostati su quei prodotti. Le statistiche per prodotto restano.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var n = WatchedProductsStore.RemoveUnwatched();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Tolti {n} prodotti senza stellina", LogLevel.Info);
}
/// <summary>
/// Scrive nei limiti del prodotto i valori consigliati dallo storico — minimo e
/// massimo insieme, perché è la coppia a definire la finestra in cui ha senso
/// puntare: un tetto senza pavimento lascia sprecare puntate su aste ancora lontane
/// dalla chiusura, un pavimento senza tetto non protegge da niente.
/// </summary>
private void ApplyAdviceToProduct(ProductViewModel? product)
{
if (product == null) return;
if (!product.ApplyAdvice())
{
MessageBox.Show(this,
$"Per \"{product.DisplayName}\" non ci sono abbastanza aste concluse " +
$"(ne servono almeno {PriceAdvisor.MinimumSample}) per ricavare un consiglio.",
"Limiti consigliati", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
PersistProductLimits();
Log($"[PRODOTTI] {product.DisplayName}: limiti consigliati applicati " +
$"({product.SuggestedMinPrice:F2} € {product.SuggestedMaxPrice:F2} €)",
LogLevel.Success);
if (product.AdviceWarns)
{
Log($"[PRODOTTI] {product.DisplayName}: il tetto è sotto il prezzo tipico di " +
"aggiudicazione — con questo numero di puntate l'articolo non conviene.",
LogLevel.Warning);
}
}
private void Products_ApplySuggestedClicked(object sender, RoutedEventArgs e)
{
var product = Products.SelectedProduct;
if (product == null)
{
MessageBox.Show(this, "Seleziona prima un prodotto dall'elenco.",
"Limiti consigliati", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
ApplyAdviceToProduct(product);
}
/// <summary>
/// Applica i limiti consigliati a tutto l'elenco.
///
/// <para>Chiede conferma dicendo <b>quante righe cambierebbe davvero</b>, non quante
/// ce ne sono: su un elenco dove la metà è già a posto, "applico a 40 prodotti"
/// sarebbe una risposta a una domanda che nessuno ha fatto. I prodotti senza abbastanza
/// storico restano intoccati, e quelli su cui il consiglio avverte vengono contati a
/// parte: sono i casi da guardare a mano.</para>
/// </summary>
private void Products_ApplyAllSuggestedClicked(object sender, RoutedEventArgs e)
{
var candidates = _productViewModels.Where(p => p.HasAdvice && !p.MatchesAdvice).ToList();
var senzaStorico = _productViewModels.Count(p => !p.HasAdvice);
if (candidates.Count == 0)
{
MessageBox.Show(this,
senzaStorico == _productViewModels.Count
? $"Nessun prodotto ha abbastanza aste concluse (ne servono almeno " +
$"{PriceAdvisor.MinimumSample}) per ricavare un consiglio."
: "I limiti consigliati sono già scritti su tutti i prodotti che ne hanno uno.",
"Limiti consigliati", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var avvisi = candidates.Count(p => p.AdviceWarns);
var messaggio =
$"Scrivo i limiti consigliati su {candidates.Count} " +
(candidates.Count == 1 ? "prodotto" : "prodotti") + ".\n\n" +
"I valori scritti a mano su quei prodotti verranno sostituiti.";
if (senzaStorico > 0)
messaggio += $"\n\n{senzaStorico} prodotti restano invariati: non hanno abbastanza aste concluse.";
if (avvisi > 0)
messaggio += $"\n\nAttenzione: su {avvisi} il tetto resta sotto il prezzo tipico di " +
"aggiudicazione — con le puntate impostate quegli articoli non convengono.";
messaggio += "\n\nProcedo?";
var answer = MessageBox.Show(this, messaggio, "Limiti consigliati",
MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
var applicati = candidates.Count(p => p.ApplyAdvice());
PersistProductLimits();
Log($"[PRODOTTI] Limiti consigliati applicati a {applicati} prodotti" +
(avvisi > 0 ? $" ({avvisi} con avviso di convenienza)" : ""),
LogLevel.Success);
}
/// <summary>
/// Rende durevoli i limiti e li rispecchia nella scheda prodotto. Le due scritture
/// vanno sempre insieme: <c>watched-products.json</c> è la fonte di verità,
/// <c>products.json</c> è il file che si apre per capire com'è impostato un articolo,
/// e disallinearli vuol dire avere due risposte alla stessa domanda.
/// </summary>
private void PersistProductLimits()
{
WatchedProductsStore.Persist();
ProductStatsStore.SyncOptionsFromWatchList();
}
/// <summary>
/// Riscrive i limiti del prodotto sulle aste di quell'articolo già presenti nel
/// monitor. Serve la conferma perché sovrascrive anche i ritocchi fatti a mano su
/// una singola asta, che è esattamente ciò che qualcuno potrebbe non volere.
/// </summary>
private void Products_ReapplyClicked(object sender, RoutedEventArgs e)
{
var product = Products.SelectedProduct;
if (product == null)
{
MessageBox.Show(this, "Seleziona prima un prodotto dall'elenco.",
"Riapplica limiti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var targets = _auctionViewModels
.Where(a => product.Rule.MatchesName(a.AuctionInfo.Name))
.ToList();
if (targets.Count == 0)
{
MessageBox.Show(this,
$"Nel monitor non ci sono aste di \"{product.DisplayName}\".",
"Riapplica limiti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Riscrivo i limiti di \"{product.DisplayName}\" su {targets.Count} " +
(targets.Count == 1 ? "asta" : "aste") + " già nel monitor.\n\n" +
"Le modifiche fatte a mano su quelle aste verranno sostituite. Procedo?",
"Riapplica limiti", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
var limits = ProductRuleResolver.Resolve(product.Rule, SettingsManager.Load());
foreach (var auction in targets)
{
auction.MinPrice = limits.MinPrice;
auction.MaxPrice = limits.MaxPrice;
auction.MaxClicks = limits.MaxClicks;
}
SaveAuctions();
// Il pannello dei dettagli mostra i valori dell'asta selezionata: se è fra
// quelle toccate, i campi devono cambiare sotto gli occhi.
if (_selectedAuction != null && targets.Contains(_selectedAuction))
UpdateAuctionSettingsDisplay(_selectedAuction);
Log($"[PRODOTTI] Limiti di {product.DisplayName} riapplicati a {targets.Count} aste",
LogLevel.Success);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.IO;
using System.Windows;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// L'interruttore dell'apprendimento nella barra del monitor, e le due operazioni
/// estreme delle Impostazioni: cancellare tutti i dati tranne la login, e tornare alle
/// impostazioni di fabbrica.
/// </summary>
public partial class MainWindow
{
// ── Apprendimento acceso / spento ────────────────────────────────
private bool _syncingLearningToggle;
private void AuctionMonitor_LearningToggled(object sender, RoutedEventArgs e)
{
if (_syncingLearningToggle) return;
var on = AuctionMonitor.LearningToggle.IsChecked == true;
var settings = SettingsManager.Load();
if (settings.LearningEnabled == on) return;
settings.LearningEnabled = on;
SettingsManager.Save(settings);
Log(on
? "[APPRENDIMENTO] Acceso: il motore decide con valore atteso, regime, duello e anticipo adattivo"
: "[APPRENDIMENTO] SPENTO: si punta solo entro i limiti (prezzo, puntate, budget, fascia oraria) con l'anticipo fisso",
on ? LogLevel.Success : LogLevel.Warning);
try { Learning.Refresh(); } catch { }
}
/// <summary>Allinea l'interruttore in barra a ciò che dicono le impostazioni.</summary>
private void RefreshLearningToggle()
{
try
{
_syncingLearningToggle = true;
AuctionMonitor.LearningToggle.IsChecked = SettingsManager.Load().LearningEnabled;
}
catch { }
finally { _syncingLearningToggle = false; }
}
// ── Manutenzione ─────────────────────────────────────────────────
private void Settings_WipeAllDataClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Cancello TUTTI i dati dell'applicazione:\n\n" +
"• i due database (aste, puntate, decisioni, prodotti seguiti, aste nel monitor, modelli appresi, registri)\n" +
"• le copie di sicurezza\n" +
"• la vecchia cartella Dati delle versioni precedenti\n\n" +
"Restano la login (sessione, riferimenti del sito, browser) e le impostazioni.\n" +
"L'operazione non è reversibile e l'applicazione si riavvia subito.\n\nProcedo?",
"Elimina tutti i dati", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
var conferma = MessageBox.Show(this,
"Ultima conferma: cancellare tutto tranne la login?",
"Elimina tutti i dati", MessageBoxButton.YesNo, MessageBoxImage.Stop);
if (conferma != MessageBoxResult.Yes) return;
try { _auctionMonitor.Stop(); } catch { }
var errors = AppDataWipe.WipeAllExceptLogin();
if (errors.Count > 0)
{
MessageBox.Show(this, "Alcuni file non si sono potuti cancellare:\n\n" + string.Join("\n", errors) +
"\n\nL'applicazione si riavvia comunque.", "Elimina tutti i dati", MessageBoxButton.OK, MessageBoxImage.Warning);
}
RestartApplication();
}
private void Settings_ResetSettingsClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Riporto tutte le impostazioni ai valori di fabbrica e riavvio l'applicazione.\n\n" +
"I dati (database, prodotti, storico, apprendimento) e la login non vengono toccati.\n\nProcedo?",
"Impostazioni di fabbrica", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
try { _auctionMonitor.Stop(); } catch { }
AppDataWipe.ResetSettings();
RestartApplication();
}
private void Settings_ExportAppLogClicked(object sender, RoutedEventArgs e)
{
var path = AskWhereToSave("Esporta il registro applicativo", $"registro-{DateTime.Now:yyyyMMdd-HHmm}.txt", "Testo|*.txt");
if (path == null) return;
try
{
var n = OperationalDatabase.Instance.ExportLog(OperationalDatabase.AppLog, path);
var m = OperationalDatabase.Instance.ExportLog(OperationalDatabase.FreeBidsLog,
Path.Combine(Path.GetDirectoryName(path)!, Path.GetFileNameWithoutExtension(path) + "-puntate.txt"));
Log($"[REGISTRI] Esportate {n} righe del registro applicativo e {m} del riscatto puntate in {Path.GetDirectoryName(path)}", LogLevel.Success);
OpenFolder(Path.GetDirectoryName(path)!);
}
catch (Exception ex)
{
Log($"[REGISTRI] Esportazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.ComponentModel;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Chiusura ordinata dell'applicazione.
///
/// Senza, alla chiusura restavano in volo i motori delle aste, la sorveglianza dei
/// prodotti e il cerchio di aggiornamento del catalogo: il processo moriva e Windows
/// recuperava tutto, ma una puntata in corso veniva troncata a metà senza che nulla
/// lo registrasse, e la richiesta di timer a 1 ms non veniva mai restituita al sistema.
/// </summary>
public partial class MainWindow
{
private bool _shutdownDone;
protected override void OnClosing(CancelEventArgs e)
{
base.OnClosing(e);
ShutdownServices();
}
private void ShutdownServices()
{
if (_shutdownDone) return;
_shutdownDone = true;
// Ogni pezzo va spento separatamente: se uno fallisce, gli altri devono
// comunque avere la loro occasione di chiudersi bene.
TryShutdown("timer interfaccia", () =>
{
_headerTimer?.Stop();
_userBannerTimer?.Stop();
_userHtmlTimer?.Stop();
});
TryShutdown("catalogo", StopCatalogAutoRefresh);
TryShutdown("sorveglianza prodotti", () => _productWatcher?.Stop());
TryShutdown("riscatto puntate", () => _freeBidsService?.Stop());
// Salva prima di spegnere il motore: fermare le aste ne cambia lo stato.
TryShutdown("salvataggio aste", SaveAuctions);
// Ferma i motori per asta e rilascia rete e risoluzione del timer di sistema.
TryShutdown("motore aste", () => _auctionMonitor?.Dispose());
TryShutdown("browser integrato", () => Browser?.EmbeddedWebView?.Dispose());
}
private static void TryShutdown(string what, Action action)
{
try { action(); }
catch (Exception ex)
{
// In chiusura non c'è più un'interfaccia a cui riportarlo.
Console.WriteLine($"[SHUTDOWN] {what}: {ex.Message}");
}
}
}
}
+341
View File
@@ -0,0 +1,341 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Sezione Statistiche: legge lo storico delle aste concluse
/// (CompletedAuctionsStore) e popola la griglia asta per asta.
/// </summary>
public partial class MainWindow
{
/// <summary>
/// Carica lo storico asta per asta.
///
/// <para>La lettura aggregata per prodotto non sta più qui: vive nella scheda
/// Prodotti, accanto ai limiti che quelle stesse cifre servono a decidere.</para>
/// </summary>
private void LoadStatistics()
{
try
{
var records = CompletedAuctionsStore.LoadAll();
StatsDetailGrid.ItemsSource = records;
StatsPillRecords.Text = records.Count == 1 ? "1 asta" : $"{records.Count} aste";
StatsPillWon.Text = $"{records.Count(r => r.WonByMe)} vinte";
// Con lo storico vuoto la griglia mostrerebbe solo intestazioni:
// meglio spiegare che si popola da sola.
var empty = records.Count == 0;
StatsEmptyState.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
StatsAuctionsPane.Visibility = empty ? Visibility.Collapsed : Visibility.Visible;
}
catch (Exception ex)
{
Log($"[STATISTICHE] Errore caricamento: {ex.Message}", LogLevel.Error);
}
}
/// <summary>Ricarica manuale delle statistiche (pulsante).</summary>
private void ReloadStatisticsButton_Click(object sender, RoutedEventArgs e)
{
LoadStatistics();
}
/// <summary>Apre la cartella dei database.</summary>
private void OpenStatsFolderButton_Click(object sender, RoutedEventArgs e)
{
try
{
var folder = AppPaths.DatabaseFolder;
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
{
Process.Start(new ProcessStartInfo
{
FileName = folder,
UseShellExecute = true
});
}
}
catch (Exception ex)
{
Log($"[STATISTICHE] Impossibile aprire la cartella: {ex.Message}", LogLevel.Error);
}
}
// ── Selezione multipla ───────────────────────────────────────────
private void SelectAllStatsButton_Click(object sender, RoutedEventArgs e)
{
StatsDetailGrid.Focus();
StatsDetailGrid.SelectAll();
}
private void DeleteSelectedStatsButton_Click(object sender, RoutedEventArgs e)
=> DeleteSelectedHistoryRows();
private void StatsDetailGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Delete) return;
e.Handled = true;
DeleteSelectedHistoryRows();
}
/// <summary>
/// Toglie dallo storico le righe selezionate. Prima la copia di sicurezza, poi la
/// cancellazione, poi le statistiche per prodotto ricalcolate da ciò che resta.
/// </summary>
private void DeleteSelectedHistoryRows()
{
var selected = StatsDetailGrid.SelectedItems.OfType<CompletedAuctionRecord>().ToList();
if (selected.Count == 0)
{
MessageBox.Show(this, "Seleziona prima una o più righe dello storico (Ctrl+clic, Maiusc+clic, o «Seleziona tutte»).",
"Storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
(selected.Count == 1
? $"Elimino dallo storico \"{selected[0].Name}\"?"
: $"Elimino dallo storico {selected.Count} aste?") +
"\n\nCon loro vanno via puntate, interrogazioni, reset e decisioni registrate. " +
"Prima salvo una copia nei backup e poi ricalcolo le statistiche per prodotto.",
"Storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
var backup = CompletedAuctionsStore.BackupNow();
var n = CompletedAuctionsStore.Remove(selected.Select(r => r.AuctionId).ToList());
var ricostruite = ProductStatsStore.RebuildFromHistory();
LoadStatistics();
LoadProducts();
Log($"[STORICO] Eliminate {n} aste; statistiche per prodotto ricalcolate su {ricostruite} aste rimaste. Copia di sicurezza in {backup}",
LogLevel.Success);
}
catch (Exception ex)
{
Log($"[STORICO] Eliminazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Svuota tutto lo storico in un colpo: osservazioni, decisioni, misure, contabilità
/// del rischio e statistiche per prodotto. Prodotti seguiti e apprendimento restano.
/// </summary>
private void WipeHistoryButton_Click(object sender, RoutedEventArgs e)
{
var count = CompletedAuctionsStore.LoadAll().Count;
if (count == 0)
{
MessageBox.Show(this, "Lo storico è già vuoto.", "Storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Svuoto completamente lo storico ({count} aste)?\n\n" +
"Vanno via tutte le aste concluse con puntate, interrogazioni, reset, decisioni, misure di rete " +
"e contabilità del rischio, e le statistiche per prodotto tornano a zero.\n" +
"Prima salvo una copia nei backup. Prodotti seguiti, aste nel monitor e apprendimento restano.\n\nProcedo?",
"Storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
var backup = CompletedAuctionsStore.BackupNow();
CompletedAuctionsStore.Clear();
ProductStatsStore.ClearStatistics();
LoadStatistics();
LoadProducts();
Log($"[STORICO] Svuotato: {count} aste tolte. Copia di sicurezza in {backup}", LogLevel.Warning);
}
catch (Exception ex)
{
Log($"[STORICO] Svuotamento non riuscito: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Azzera le statistiche registrate: il dialogo dice cosa c'è e quanto pesa, e
/// l'utente sceglie voce per voce. Vedi <see cref="StatsWipe"/>.
/// </summary>
private void ClearStatsButton_Click(object sender, RoutedEventArgs e)
{
var dialog = new Dialogs.WipeStatsDialog { Owner = this };
if (dialog.ShowDialog() != true || dialog.Result is not { } report) return;
LoadStatistics();
LoadProducts();
if (Learning.IsVisible) Learning.Refresh();
Log($"[STATISTICHE] Azzeramento: {report.Summary}. " +
$"{report.FilesDeleted} file tolti, {report.BytesFreed / (1024.0 * 1024.0):F1} MB liberati",
report.Errors.Count == 0 ? LogLevel.Success : LogLevel.Warning);
}
/// <summary>
/// Analizza lo storico e toglie le aste incomplete secondo le regole scelte
/// dall'utente. Il dialogo mostra il conto prima di toccare qualunque cosa.
/// </summary>
private void CleanupStatsButton_Click(object sender, RoutedEventArgs e)
{
var records = CompletedAuctionsStore.LoadAll();
if (records.Count == 0)
{
MessageBox.Show(this, "Lo storico e' vuoto: non c'e' nulla da pulire.",
"Pulizia", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var dialog = new Dialogs.StatsCleanupDialog(records) { Owner = this };
if (dialog.ShowDialog() != true) return;
// Le statistiche per prodotto sono un archivio a parte, alimentato asta per
// asta man mano che chiudono: dopo una pulizia descriverebbero aste che non ci
// sono più — e sono loro a decidere i limiti consigliati e i tetti di puntate.
// Si ricostruiscono da zero dallo storico appena pulito, l'unica fonte rimasta.
var ricostruite = 0;
try
{
ricostruite = ProductStatsStore.RebuildFromHistory();
}
catch (Exception ex)
{
Log($"[STATISTICHE] Ricalcolo per prodotto non riuscito: {ex.Message}", LogLevel.Warning);
}
LoadStatistics();
LoadProducts();
Log($"[STORICO] Pulizia: tolte {dialog.RemovedCount} aste; statistiche per prodotto " +
$"ricalcolate su {ricostruite} aste rimaste. Copia di sicurezza in {dialog.BackupPath}",
LogLevel.Success);
}
/// <summary>
/// Chiede al server quante puntate ha speso il vincitore delle aste gia' concluse.
///
/// <para>Il dato non scade: le aste registrate prima che l'applicazione sapesse
/// leggerlo non sono perse, basta richiederle. Senza questo recupero le statistiche
/// sulle puntate ripartirebbero da zero e servirebbero mesi per averne abbastanza.</para>
/// </summary>
private async void BackfillWinnerBidsButton_Click(object sender, RoutedEventArgs e)
{
var missing = Services.WinnerBidsBackfill.FindMissing();
if (missing.Count == 0)
{
MessageBox.Show(this,
"Tutte le aste concluse hanno gia' il conteggio delle puntate del vincitore.",
"Recupero", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Chiedo al server le puntate del vincitore per {missing.Count} aste concluse.\n\n" +
"Serve una richiesta per asta, e viaggia in corsia di fondo dietro alle aste " +
"in corso: misurato sul campo, circa un'asta e mezza al secondo, quindi " +
$"{Math.Max(1, (int)(missing.Count * 1.6 / 60))} minuti circa.\n\n" +
"Puoi continuare a usare l'applicazione: le aste in corso hanno la precedenza " +
"e non ne risentono.\n\nProcedo?",
"Recupera puntate dei vincitori", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var transport = _auctionMonitor?.GetApiClient()?.Transport;
if (transport == null)
{
Log("[RECUPERO] Trasporto HTTP non disponibile", LogLevel.Error);
return;
}
var button = sender as System.Windows.Controls.Button;
if (button != null) button.IsEnabled = false;
try
{
var backfill = new Services.WinnerBidsBackfill(transport);
backfill.OnLog += m => Dispatcher.Invoke(() => Log(m, LogLevel.Warning));
Log($"[RECUPERO] Avvio: {missing.Count} aste da completare", LogLevel.Info);
// La finestra di avanzamento non blocca l'applicazione: il recupero va
// piano di proposito e intanto si può fare altro.
var esito = await Dialogs.ProgressDialog.RunAsync(this, "Recupero delle puntate dei vincitori",
$"{missing.Count:N0} aste da completare, una richiesta alla volta in corsia di fondo.",
(progress, ct) =>
{
var lastReported = 0;
backfill.OnProgress += (done, total, updated) =>
{
progress.Report(new ProgressStep(done, total, $"{updated:N0} aggiornate"));
// Una riga di registro ogni cinquanta: una per asta seppellirebbe
// tutto il resto sotto migliaia di righe.
if (done - lastReported < 50 && done != total) return;
lastReported = done;
Dispatcher.Invoke(() =>
Log($"[RECUPERO] {done}/{total} aste esaminate, {updated} aggiornate", LogLevel.Info));
};
return backfill.RunAsync(0, ct);
}, modal: false);
if (esito.Error != null)
{
Log($"[RECUPERO] Non riuscito: {esito.Error.Message}", LogLevel.Error);
return;
}
if (esito.Cancelled)
{
LoadStatistics();
LoadProducts();
Log("[RECUPERO] Interrotto dall'utente: le aste già completate restano completate", LogLevel.Warning);
return;
}
var result = esito.Result!;
LoadStatistics();
LoadProducts();
Log($"[RECUPERO] Fatto: {result.Updated} aggiornate, {result.Skipped} senza dato utilizzabile, " +
$"{result.Failed} non riuscite", LogLevel.Success);
MessageBox.Show(this,
$"Aggiornate {result.Updated} aste su {result.Examined}.\n\n" +
(result.Skipped > 0
? $"{result.Skipped} senza un conteggio utilizzabile: il server non l'ha dato, " +
"oppure non superava il controllo di coerenza col prezzo finale.\n"
: "") +
(result.Failed > 0 ? $"{result.Failed} non riuscite per errori di rete.\n" : "") +
"\nLe statistiche per prodotto sono gia' aggiornate.",
"Recupero completato", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
Log($"[RECUPERO] Interrotto: {ex.Message}", LogLevel.Error);
}
finally
{
if (button != null) button.IsEnabled = true;
}
}
}
}
+360
View File
@@ -0,0 +1,360 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using AutoBidder.Utilities;
using AutoBidder.ViewModels;
namespace AutoBidder
{
/// <summary>
/// UI update methods and selected auction details
/// </summary>
public partial class MainWindow
{
/// <summary>Firma dell'ultimo log disegnato: asta, righe e ultima voce.</summary>
private (string Id, int Count, DateTime Last, int Repeat) _lastLogSignature;
/// <summary>Firma dell'ultima griglia puntatori disegnata.</summary>
private (string Id, int Count, int TotalBids) _lastBiddersSignature;
private void UpdateAuctionLog(AuctionViewModel auction)
{
try
{
var auctionInfo = auction.AuctionInfo;
var log = auctionInfo.AuctionLog;
if (log == null) return;
// Ricostruire il documento significa creare un Paragraph e un Run per ogni
// riga, fino a duecento, a ogni risposta del server. Se il log non è
// cambiato non c'è nulla da ridisegnare.
var last = log.Count > 0 ? log[^1] : null;
var signature = (auction.AuctionId, log.Count,
last?.Timestamp ?? DateTime.MinValue, last?.RepeatCount ?? 0);
if (signature == _lastLogSignature) return;
_lastLogSignature = signature;
var logBox = SelectedAuctionLog;
var doc = logBox.Document;
doc.Blocks.Clear();
foreach (var entry in log)
{
// Color coding based on structured log level
Brush color = entry.Level switch
{
Models.AuctionLogLevel.Error => new SolidColorBrush(Color.FromRgb(232, 17, 35)), // Red
Models.AuctionLogLevel.Warning => new SolidColorBrush(Color.FromRgb(255, 183, 0)), // Yellow/Orange
Models.AuctionLogLevel.Success => new SolidColorBrush(Color.FromRgb(0, 216, 0)), // Green
Models.AuctionLogLevel.Bid => new SolidColorBrush(Color.FromRgb(0, 216, 0)), // Green
Models.AuctionLogLevel.Strategy => new SolidColorBrush(Color.FromRgb(200, 160, 255)),// Purple
Models.AuctionLogLevel.Timing => new SolidColorBrush(Color.FromRgb(150, 150, 150)), // Gray
Models.AuctionLogLevel.Debug => new SolidColorBrush(Color.FromRgb(120, 120, 120)), // Dark gray
_ => new SolidColorBrush(Color.FromRgb(100, 180, 255)) // Light Blue
};
var repeatSuffix = entry.RepeatCount > 1 ? $" (x{entry.RepeatCount})" : "";
var line = $"[{entry.TimeDisplay}] [{entry.LevelLabel}] {entry.Message}{repeatSuffix}";
var p = new System.Windows.Documents.Paragraph { Margin = new Thickness(0, 2, 0, 2) };
var r = new System.Windows.Documents.Run(line) { Foreground = color };
p.Inlines.Add(r);
doc.Blocks.Add(p);
}
// Auto-scroll if near bottom
var viewer = logBox;
var vpos = viewer.VerticalOffset;
var vmax = viewer.ExtentHeight - viewer.ViewportHeight;
if (vmax - vpos < 40)
{
viewer.ScrollToEnd();
}
}
catch { }
}
private void RefreshBiddersGrid(AuctionViewModel auction)
{
try
{
// Copia sotto lucchetto: il motore riscrive il dizionario dal proprio thread.
var bidders = auction.AuctionInfo.SnapshotBidderStats();
// La quota si calcola qui: il singolo puntatore non conosce il totale dell'asta.
var totalBids = bidders.Sum(b => b.BidCount);
foreach (var bidder in bidders)
{
bidder.SharePercent = totalBids > 0 ? bidder.BidCount * 100.0 / totalBids : 0;
}
// Riassegnare ItemsSource ricostruisce l'intera griglia e la fa lampeggiare:
// si rifà solo quando i numeri sono davvero cambiati.
var signature = (auction.AuctionId, bidders.Count, totalBids);
if (signature != _lastBiddersSignature)
{
_lastBiddersSignature = signature;
SelectedAuctionBiddersGrid.ItemsSource = null;
SelectedAuctionBiddersGrid.ItemsSource = bidders;
SelectedAuctionBiddersCount.Text = bidders.Count == 0
? "Nessun dato sui puntatori."
: $"{bidders.Count} puntatori · {totalBids} puntate osservate";
}
// ?? NUOVO: Aggiorna il contatore della storia puntate con limite configurato
var settings = SettingsManager.Load();
var maxEntries = settings?.MaxBidHistoryEntries ?? 20;
var historyCount = auction.BidHistoryEntries?.Count ?? 0;
var bidHistoryCountTextBlock = AuctionMonitor.FindName("BidHistoryCount") as TextBlock;
if (bidHistoryCountTextBlock != null)
{
// Mostra "Ultime 20 puntate" se il limite attivo
if (maxEntries > 0)
{
bidHistoryCountTextBlock.Text = $"Ultime {maxEntries} puntate";
}
else
{
bidHistoryCountTextBlock.Text = $"Ultime puntate: {historyCount}";
}
}
}
catch { }
}
private void UpdateAuctionSettingsDisplay(AuctionViewModel auction)
{
try
{
// Blocca temporaneamente i TextChanged per evitare loop di aggiornamento
_isUpdatingSelection = true;
SelectedAuctionName.Text = auction.Name;
SelectedBidBeforeDeadlineMs.Text = auction.AuctionInfo.BidBeforeDeadlineMs.ToString();
SelectedMinPrice.Text = auction.MinPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
SelectedMaxPrice.Text = auction.MaxPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
SelectedMaxClicks.Text = auction.MaxClicks.ToString();
AuctionMonitor.SelectedMaxSpend.Text = auction.AuctionInfo.MaxTotalSpendEuro
.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
AuctionMonitor.SelectedStopAtBreakEven.IsChecked = auction.AuctionInfo.StopAtBreakEven;
var url = auction.AuctionInfo.OriginalUrl;
if (string.IsNullOrEmpty(url))
url = $"https://it.bidoo.com/auction.php?a=asta_{auction.AuctionId}";
SelectedAuctionUrl.Text = url;
ResetSettingsButton.IsEnabled = true;
ClearBiddersButton.IsEnabled = true;
ClearLogButton.IsEnabled = true;
UpdateAuctionLog(auction);
RefreshBiddersGrid(auction);
_isUpdatingSelection = false;
}
catch
{
_isUpdatingSelection = false;
}
}
private void UpdateTotalCount()
{
MonitorateTitle.Text = $"Aste monitorate: {_auctionViewModels.Count}";
}
private void UpdateGlobalControlButtons()
{
try
{
var hasAuctions = _auctionViewModels.Count > 0;
if (!hasAuctions)
{
// Nessuna asta: tutti disabilitati
StartButton.IsEnabled = false;
StartButton.Opacity = 0.4;
PauseAllButton.IsEnabled = false;
PauseAllButton.Opacity = 0.4;
StopButton.IsEnabled = false;
StopButton.Opacity = 0.4;
return;
}
// Conta quante aste possono eseguire ogni azione
int canStartCount = _auctionViewModels.Count(a => a.CanStart);
int canPauseCount = _auctionViewModels.Count(a => a.CanPause);
int canStopCount = _auctionViewModels.Count(a => a.CanStop);
// AVVIA TUTTI: abilitato se ALMENO UNA asta pu essere avviata
// Scuro se NESSUNA asta pu essere avviata (tutte gi avviate)
StartButton.IsEnabled = canStartCount > 0;
StartButton.Opacity = canStartCount > 0 ? 1.0 : 0.4;
// OSSERVA TUTTE: abilitato se ALMENO UNA asta non e' gia' in sola osservazione
PauseAllButton.IsEnabled = canPauseCount > 0;
PauseAllButton.Opacity = canPauseCount > 0 ? 1.0 : 0.4;
// FERMA TUTTI: abilitato se ALMENO UNA asta pu essere fermata
// Scuro se NESSUNA asta pu essere fermata (tutte gi ferme)
StopButton.IsEnabled = canStopCount > 0;
StopButton.Opacity = canStopCount > 0 ? 1.0 : 0.4;
}
catch { }
}
private void MultiAuctionsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (MultiAuctionsGrid.SelectedItem is AuctionViewModel selected)
{
_selectedAuction = selected;
UpdateSelectedAuctionDetails(selected);
}
}
private void GridOpenAuction_Click(object sender, RoutedEventArgs e)
{
try
{
if (sender is FrameworkElement element && element.DataContext is AuctionViewModel vm)
{
var url = vm.AuctionInfo.OriginalUrl;
if (string.IsNullOrEmpty(url))
url = $"https://it.bidoo.com/auction.php?a=asta_{vm.AuctionId}";
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
}
catch (Exception ex)
{
Log($"[ERRORE] Apertura asta: {ex.Message}", LogLevel.Error);
}
}
private void ExportSelectedAuction_Click(object sender, RoutedEventArgs e)
{
try
{
if (sender is FrameworkElement element && element.DataContext is AuctionViewModel vm)
{
MessageBox.Show(this, $"Esportazione singola asta non ancora implementata.\nUsa 'Esporta Aste' dalla toolbar.", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Esportazione asta: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Resetta le impostazioni dell'asta selezionata ai valori predefiniti
/// </summary>
private void ResetSettingsButton_Click(object sender, RoutedEventArgs e)
{
try
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var settings = SettingsManager.Load();
// Resetta ai valori predefiniti dalle impostazioni
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs;
_selectedAuction.AuctionInfo.BidLeadIsManual = false; // torna all'anticipo adattivo
_selectedAuction.MinPrice = settings.DefaultMinPrice;
_selectedAuction.MaxPrice = settings.DefaultMaxPrice;
_selectedAuction.MaxClicks = settings.DefaultMaxClicks;
// Aggiorna UI
UpdateAuctionSettingsDisplay(_selectedAuction);
// Salva
SaveAuctions();
Log($"[RESET] Impostazioni ripristinate ai valori predefiniti per: {_selectedAuction.Name}", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Reset impostazioni: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante il reset: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// Pulisce la lista degli utenti che hanno puntato sull'asta selezionata
/// </summary>
private void ClearBiddersButton_Click(object sender, RoutedEventArgs e)
{
try
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var result = MessageBox.Show(
$"Pulire la lista degli utenti per questa asta?\n\n{_selectedAuction.Name}\n\nLa lista degli utenti che hanno puntato verr svuotata.",
"Conferma Pulizia",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
return;
// Pulisci la lista bidders
_selectedAuction.AuctionInfo.BidderStats.Clear();
// Aggiorna UI
RefreshBiddersGrid(_selectedAuction);
Log($"[CLEAR] Lista utenti pulita per: {_selectedAuction.Name}", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Pulizia lista utenti: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante la pulizia: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// Pulisce il log dell'asta selezionata
/// </summary>
private void ClearLogButton_Click(object sender, RoutedEventArgs e)
{
try
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Pulisci il log dell'asta
_selectedAuction.AuctionInfo.AuctionLog.Clear();
// Aggiorna UI
UpdateAuctionLog(_selectedAuction);
Log($"[CLEAR] Log pulito per: {_selectedAuction.Name}", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Pulizia log asta: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore durante la pulizia: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Text.RegularExpressions;
namespace AutoBidder
{
/// <summary>
/// URL parsing and validation utilities
/// </summary>
public partial class MainWindow
{
private bool IsValidAuctionUrl(string url)
{
try
{
var uri = new Uri(url);
var host = uri.Host.ToLowerInvariant();
// Valida dominio Bidoo
if (!host.Contains("bidoo.com") && !host.Contains("bidoo.it") &&
!host.Contains("bidoo.fr") && !host.Contains("bidoo.es") &&
!host.Contains("bidoo.de"))
{
return false;
}
// Valida path asta
return uri.AbsolutePath.Contains("/asta/") || uri.Query.Contains("a=");
}
catch
{
return false;
}
}
private string? ExtractAuctionId(string url)
{
try
{
var uri = new Uri(url);
// Formato nuovo: /asta/nome-prodotto-81204324
var match = Regex.Match(uri.AbsolutePath, @"/asta/[^/]*-(\d{8,})");
if (match.Success)
{
return match.Groups[1].Value;
}
// Formato vecchio: /asta/81204324
match = Regex.Match(uri.AbsolutePath, @"/asta/(\d{8,})");
if (match.Success)
{
return match.Groups[1].Value;
}
// Formato query: ?a=Galaxy_S25_Ultra_256GB_81204324
match = Regex.Match(uri.Query, @"[?&]a=([^&]+)");
if (match.Success)
{
var aValue = match.Groups[1].Value;
// Estrai ID numerico finale (8+ cifre)
var idMatch = Regex.Match(aValue, @"(\d{8,})");
if (idMatch.Success)
{
return idMatch.Groups[1].Value;
}
}
return null;
}
catch
{
return null;
}
}
private string? ExtractProductName(string url)
{
try
{
var uri = new Uri(url);
// Formato query: ?a=Galaxy_S25_Ultra_256GB_81204324
var match = Regex.Match(uri.Query, @"[?&]a=([^&]+)");
if (match.Success)
{
var aValue = match.Groups[1].Value;
// Rimuovi l'ID finale e gli underscore
var nameMatch = Regex.Match(aValue, @"^(.+?)_(\d{8,})$");
if (nameMatch.Success)
{
var productName = nameMatch.Groups[1].Value;
productName = productName.Replace('_', ' ');
return productName;
}
}
// Formato path: /asta/galaxy-s25-ultra-256gb-81204324
match = Regex.Match(uri.AbsolutePath, @"/asta/(.+?)-(\d{8,})");
if (match.Success)
{
var productName = match.Groups[1].Value;
productName = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(
productName.Replace('-', ' ')
);
return productName;
}
return null;
}
catch
{
return null;
}
}
}
}
+422
View File
@@ -0,0 +1,422 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using AutoBidder.Services;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// User info and banner management - REFACTORED con SessionService
/// </summary>
public partial class MainWindow
{
// Creati in InitializeUserInfo(), non nel costruttore.
private System.Windows.Threading.DispatcherTimer? _userBannerTimer;
private System.Windows.Threading.DispatcherTimer? _userHtmlTimer;
private System.Windows.Threading.DispatcherTimer? _toConfirmTimer;
private SessionService _sessionService = null!;
/// <summary>
/// Aste vinte in attesa di conferma su Bidoo, o <c>-1</c> finché non è arrivata una
/// risposta utilizzabile. È un <c>int</c> e non un <c>int?</c> perché lo scrive la
/// rete e lo legge il battito dell'interfaccia: un annullabile sono due campi, e
/// due campi si possono leggere a metà aggiornamento.
/// </summary>
private int _auctionsToConfirmRaw = Unknown;
private const int Unknown = -1;
/// <summary>Aste da confermare, o <c>null</c> se ancora non si sa.</summary>
private int? AuctionsToConfirm
{
get
{
var value = System.Threading.Volatile.Read(ref _auctionsToConfirmRaw);
return value < 0 ? null : value;
}
}
private void InitializeUserInfoTimers()
{
// Timer per aggiornamento dati utente da HTML ogni 5 minuti (PRINCIPALE)
_userHtmlTimer = new System.Windows.Threading.DispatcherTimer();
_userHtmlTimer.Interval = TimeSpan.FromMinutes(5);
_userHtmlTimer.Tick += UserHtmlTimer_Tick;
_userHtmlTimer.Start();
// Timer per aggiornamento banner API ogni 10 minuti (SECONDARIO - fallback)
_userBannerTimer = new System.Windows.Threading.DispatcherTimer();
_userBannerTimer.Interval = TimeSpan.FromMinutes(10);
_userBannerTimer.Tick += UserBannerTimer_Tick;
_userBannerTimer.Start();
// Aste da confermare: una richiesta da un byte, quindi si può chiedere spesso.
// Un minuto è il compromesso fra "il numero compare subito dopo una vincita"
// e "non si tempesta il server per un dato che cambia di rado".
_toConfirmTimer = new System.Windows.Threading.DispatcherTimer();
_toConfirmTimer.Interval = TimeSpan.FromSeconds(60);
_toConfirmTimer.Tick += (_, _) => _ = RefreshAuctionsToConfirmAsync();
_toConfirmTimer.Start();
}
/// <summary>
/// Rilegge da Bidoo quante aste vinte aspettano conferma.
///
/// <para>Una risposta non utilizzabile <b>non</b> azzera il valore noto: se la rete
/// cade, la barra continua a mostrare l'ultimo numero certo invece di far sparire
/// una vincita che esiste.</para>
/// </summary>
private async Task RefreshAuctionsToConfirmAsync()
{
try
{
var session = _auctionMonitor.GetSession();
if (session == null || string.IsNullOrEmpty(session.Username))
{
System.Threading.Volatile.Write(ref _auctionsToConfirmRaw, Unknown);
return;
}
var count = await _auctionMonitor.GetAuctionsWonToConfirmAsync().ConfigureAwait(false);
if (count.HasValue) System.Threading.Volatile.Write(ref _auctionsToConfirmRaw, count.Value);
}
catch { /* la barra non deve mai disturbare il resto */ }
}
private void InitializeSessionService()
{
// NUOVO: Inizializza SessionService
_sessionService = new SessionService(_auctionMonitor.GetApiClient());
// Event handlers
_sessionService.OnLog += (msg) => Log(msg, LogLevel.Info);
_sessionService.OnSessionChanged += (session) =>
{
Dispatcher.Invoke(() => SetUserBanner(session.Username, session.RemainingBids));
};
// La sessione salvata va ripresa, altrimenti salvarla non serve a niente.
_ = RestoreSavedSessionAsync();
}
/// <summary>
/// Riprende la sessione salvata su disco e la riattiva verso Bidoo.
///
/// Senza questo passaggio il cookie veniva scritto in session.dat e mai più letto:
/// a ogni avvio l'applicazione risultava "Non connesso" e bisognava reincollarlo,
/// pur avendone una copia valida sul disco.
///
/// Non blocca l'avvio: la validazione richiede un giro di rete, quindi la finestra
/// si apre subito e il banner si aggiorna quando la risposta arriva.
/// </summary>
private async Task RestoreSavedSessionAsync()
{
try
{
var saved = _sessionService.LoadSession();
if (saved == null || string.IsNullOrWhiteSpace(saved.CookieString)) return;
Log("[SESSION] Sessione salvata trovata: verifica in corso…", LogLevel.Info);
// Il cookie potrebbe essere scaduto da giorni: si riattiva contro il server
// invece di fidarsi del file.
var result = await _sessionService.ValidateAndActivateSessionAsync(
saved.CookieString, saved.Username);
if (result.Success && result.Session != null)
{
// Il motore deve conoscere il cookie per poter interrogare e puntare.
_auctionMonitor.InitializeSessionWithCookie(
saved.CookieString, result.Session.Username);
Dispatcher.Invoke(() =>
{
SetUserBanner(result.Session.Username, result.Session.RemainingBids);
RefreshSettingsSessionStatus();
});
Log($"[SESSION] Riconnesso come {result.Session.Username}", LogLevel.Success);
}
else
{
// Si lascia il cookie sul disco: l'utente lo vede in Impostazioni e
// decide se rinnovarlo dal browser.
Log($"[SESSION] Sessione salvata non più valida: {result.ErrorMessage}", LogLevel.Warning);
}
}
catch (Exception ex)
{
Log($"[SESSION] Ripristino non riuscito: {ex.Message}", LogLevel.Error);
}
}
private void SetUserBanner(string username, int? remainingBids)
{
try
{
var session = _sessionService?.GetCurrentSession();
if (!string.IsNullOrEmpty(username))
{
// === CONNESSO ===
// Puntate, credito e aste da confermare li ridisegna il battito da un
// secondo (RefreshAccountPills) leggendo la sessione viva: scriverli
// anche qui non aggiungerebbe nulla e riporterebbe il rischio di due
// fonti che si contraddicono.
AuctionMonitor.UpdateAccountStatus(
remainingBids ?? session?.RemainingBids,
session is null ? null : (decimal)session.ShopCredit,
AuctionsToConfirm);
// Appena la sessione è viva si può finalmente chiedere quante vincite
// aspettano conferma: prima non si poteva sapere.
_ = RefreshAuctionsToConfirmAsync();
// === SIDEBAR - Mostra dati utente ===
SidebarUsernameText.Text = username;
SidebarUsernameText.Foreground = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(0, 216, 0)); // Verde
SidebarUsernameText.FontWeight = System.Windows.FontWeights.Bold;
SidebarUsernameText.ToolTip = $"Connesso come {username} - Click per disconnettere";
// Solo l'ID: l'indirizzo di posta non aggiungeva nulla di utile qui.
if (session?.UserId > 0)
{
SidebarUserIdText.Text = $"ID: {session.UserId}";
SidebarUserIdText.Visibility = System.Windows.Visibility.Visible;
}
else
{
SidebarUserIdText.Visibility = System.Windows.Visibility.Collapsed;
}
SidebarUserDetailsPanel.Visibility = System.Windows.Visibility.Visible;
}
else
{
// === NON CONNESSO ===
// Senza sessione questi numeri non esistono: un trattino lo dice,
// uno zero mentirebbe.
System.Threading.Volatile.Write(ref _auctionsToConfirmRaw, Unknown);
AuctionMonitor.UpdateAccountStatus(null, null, null);
// Nascondi indicatore limite
MinBidsLimitIndicator.Visibility = Visibility.Collapsed;
// === SIDEBAR - Mostra "Non connesso" ===
SidebarUsernameText.Text = "Non connesso";
SidebarUsernameText.Foreground = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(255, 82, 82)); // Rosso chiaro (#FF5252)
SidebarUsernameText.FontWeight = System.Windows.FontWeights.Bold;
SidebarUsernameText.ToolTip = "Non connesso - Click per accedere tramite browser";
// Nascondi dettagli (ID + Email)
SidebarUserDetailsPanel.Visibility = System.Windows.Visibility.Collapsed;
}
}
catch { }
}
private async void UserBannerTimer_Tick(object? sender, EventArgs e)
{
// Usa SessionService per refresh
if (_sessionService != null)
{
await _sessionService.RefreshUserInfoAsync();
}
}
private async void UserHtmlTimer_Tick(object? sender, EventArgs e)
{
// Usa SessionService per refresh
if (_sessionService != null)
{
await _sessionService.RefreshUserInfoAsync();
}
}
/// <summary>
/// Carica sessione salvata
/// </summary>
private void LoadSavedSession()
{
try
{
var session = _sessionService?.GetCurrentSession();
if (session != null && session.IsValid)
{
StartButton.IsEnabled = true;
Log($"[SESSION] Ripristino sessione per: {session.Username}", LogLevel.Info);
// Aggiorna UI con stato connesso (ottimistico)
SetUserBanner(session.Username, session.RemainingBids);
// Verifica validit cookie in background
System.Threading.Tasks.Task.Run(async () =>
{
try
{
Log("[SESSION] Verifica validit sessione...", LogLevel.Info);
var success = await _auctionMonitor.UpdateUserInfoAsync();
var updatedSession = _auctionMonitor.GetSession();
Dispatcher.Invoke(() =>
{
if (success && updatedSession != null && !string.IsNullOrEmpty(updatedSession.Username))
{
SetUserBanner(updatedSession.Username, updatedSession.RemainingBids);
Log($"[SESSION] Sessione valida - {updatedSession.Username} ({updatedSession.RemainingBids} puntate)", LogLevel.Success);
}
else
{
SetUserBanner(string.Empty, 0);
Log("[SESSION] Sessione scaduta", LogLevel.Warning);
CheckBrowserCookieAfterWebViewReady();
}
});
}
catch (Exception ex)
{
Dispatcher.Invoke(() =>
{
SetUserBanner(string.Empty, 0);
Log($"[SESSION] Errore verifica sessione: {ex.Message}", LogLevel.Warning);
CheckBrowserCookieAfterWebViewReady();
});
}
});
}
else
{
Log("[SESSION] Nessuna sessione salvata", LogLevel.Info);
CheckBrowserCookieAfterWebViewReady();
SetUserBanner(string.Empty, 0);
}
}
catch (Exception ex)
{
Log($"[ERRORE] Caricamento sessione: {ex.Message}", LogLevel.Error);
CheckBrowserCookieAfterWebViewReady();
SetUserBanner(string.Empty, 0);
}
}
/// <summary>
/// Attende che WebView sia pronta, poi verifica presenza cookie
/// </summary>
private void CheckBrowserCookieAfterWebViewReady()
{
System.Threading.Tasks.Task.Run(async () =>
{
try
{
// Aspetta che WebView sia inizializzata (max 60 secondi)
var webViewReady = await WaitForWebViewInitAsync(60);
if (!webViewReady)
{
await Dispatcher.InvokeAsync(() =>
{
Log("[WARN] WebView non inizializzata dopo 60 secondi", LogLevel.Warning);
Log("[INFO] Per accedere:", LogLevel.Info);
Log("[INFO] 1. Click su 'Non connesso' nella sidebar", LogLevel.Info);
Log("[INFO] 2. Si aprir la scheda Browser", LogLevel.Info);
Log("[INFO] 3. Fai login su Bidoo", LogLevel.Info);
Log("[INFO] 4. La connessione sar automatica", LogLevel.Info);
});
return;
}
// WebView pronta - verifica cookie
await Dispatcher.InvokeAsync(async () =>
{
var browserCookie = await GetCookieFromWebView();
if (string.IsNullOrEmpty(browserCookie))
{
Log("[INFO] Nessun cookie nel browser", LogLevel.Info);
Log("[INFO] Per accedere:", LogLevel.Info);
Log("[INFO] 1. Click su 'Non connesso' nella sidebar", LogLevel.Info);
Log("[INFO] 2. Si aprir la scheda Browser", LogLevel.Info);
Log("[INFO] 3. Fai login su Bidoo", LogLevel.Info);
Log("[INFO] 4. La connessione sar automatica", LogLevel.Info);
}
else
{
Log("[INFO] Cookie rilevato nel browser - importazione in corso...", LogLevel.Info);
}
});
}
catch (Exception ex)
{
Log($"[WARN] Errore verifica cookie: {ex.Message}", LogLevel.Warning);
}
});
}
/// <summary>
/// Ridisegna subito la barra del conto dopo una puntata, senza aspettare il battito.
///
/// <para>Prima aggiornava il numero solo se era maggiore di zero: finite le puntate,
/// la barra restava sull'ultimo valore positivo — cioè mentiva proprio nel momento
/// in cui contava di più.</para>
/// </summary>
public void UpdateRemainingBidsDisplay()
{
try
{
RefreshAccountPills();
}
catch (Exception ex)
{
Log($"[ERROR] Errore aggiornamento banner: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Indicatore del limite minimo puntate, accanto al saldo.
///
/// <para>Il colore va ricalcolato anche a zero puntate: era proprio il caso in cui
/// prima restava dell'ultimo colore utile, cioè verde, mentre il conto era vuoto.</para>
/// </summary>
private void UpdateMinBidsIndicator(int minBidsLimit)
{
try
{
if (minBidsLimit <= 0)
{
MinBidsLimitIndicator.Visibility = Visibility.Collapsed;
return;
}
MinBidsLimitIndicator.Visibility = Visibility.Visible;
MinBidsLimitIndicator.Text = $"({minBidsLimit})";
MinBidsLimitIndicator.ToolTip =
$"Limite minimo puntate attivo: il bot non scende sotto {minBidsLimit} puntate";
var session = _auctionMonitor.GetSession();
if (session == null || string.IsNullOrEmpty(session.Username))
{
MinBidsLimitIndicator.SetResourceReference(
System.Windows.Controls.TextBlock.ForegroundProperty, "Brush.TextFaint");
return;
}
var key =
session.RemainingBids <= minBidsLimit ? "Brush.Danger" :
session.RemainingBids <= minBidsLimit + 10 ? "Brush.Warning" :
"Brush.Success";
MinBidsLimitIndicator.SetResourceReference(
System.Windows.Controls.TextBlock.ForegroundProperty, key);
}
catch { }
}
}
}
+355
View File
@@ -0,0 +1,355 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Web.WebView2.Core;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Gestione WebView2: pre-caricamento e estrazione cookie
/// </summary>
public partial class MainWindow
{
private bool _isWebViewInitialized = false;
private TaskCompletionSource<bool>? _webViewInitCompletionSource;
/// <summary>
/// Inizializza WebView2 in background all'avvio per pre-caricare il browser
/// </summary>
private async void InitializeWebView2()
{
try
{
if (EmbeddedWebView == null)
{
Log("[WARN] WebView2 non disponibile", LogLevel.Warning);
_webViewInitCompletionSource?.TrySetResult(false);
return;
}
Log("[BROWSER] Inizializzazione WebView2 in background...", LogLevel.Info);
// Aspetta un attimo che l'UI sia completamente caricata
await System.Threading.Tasks.Task.Delay(500);
// ? FIX: WebView2 si inizializza SOLO se visibile
// Salva tab corrente e switcha temporaneamente a Browser
var wasVisible = Browser.Visibility == Visibility.Visible;
var currentTab = TabAsteAttive.IsChecked == true ? "AsteAttive" :
TabCerca.IsChecked == true ? "Cerca" :
TabProdotti.IsChecked == true ? "Prodotti" :
TabBrowser.IsChecked == true ? "Browser" :
TabPuntateGratis.IsChecked == true ? "PuntateGratis" :
TabDatiStatistici.IsChecked == true ? "DatiStatistici" :
TabImpostazioni.IsChecked == true ? "Impostazioni" : "AsteAttive";
if (!wasVisible)
{
await Dispatcher.InvokeAsync(() =>
{
Browser.Visibility = Visibility.Visible;
});
await Task.Delay(100);
}
// Specifica UserDataFolder esplicito
var userDataFolder = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AutoBidder",
"WebView2"
);
// Crea directory se non esiste
System.IO.Directory.CreateDirectory(userDataFolder);
// Crea environment con UserDataFolder esplicito
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: userDataFolder
);
// Inizializza WebView con environment
await EmbeddedWebView.EnsureCoreWebView2Async(env);
// Ripristina tab originale se necessario
if (!wasVisible)
{
await Dispatcher.InvokeAsync(() =>
{
Browser.Visibility = Visibility.Collapsed;
// Ripristina tab originale
switch (currentTab)
{
case "AsteAttive":
TabAsteAttive.IsChecked = true;
AuctionMonitor.Visibility = Visibility.Visible;
break;
case "Cerca":
// "Cerca" mostra lo stesso controllo Browser, in modalità catalogo.
TabCerca.IsChecked = true;
Browser.Visibility = Visibility.Visible;
break;
case "Prodotti":
TabProdotti.IsChecked = true;
Products.Visibility = Visibility.Visible;
break;
case "PuntateGratis":
TabPuntateGratis.IsChecked = true;
FreeBids.Visibility = Visibility.Visible;
break;
case "DatiStatistici":
TabDatiStatistici.IsChecked = true;
StatisticsPanel.Visibility = Visibility.Visible;
break;
case "Impostazioni":
TabImpostazioni.IsChecked = true;
Settings.Visibility = Visibility.Visible;
break;
}
});
}
if (EmbeddedWebView.CoreWebView2 != null)
{
_isWebViewInitialized = true;
// Pre-carica la pagina di Bidoo in background
EmbeddedWebView.CoreWebView2.Navigate("https://it.bidoo.com");
Log("[BROWSER] WebView2 inizializzato e pre-caricato", LogLevel.Success);
// Registra evento per rilevare login automatico
EmbeddedWebView.CoreWebView2.NavigationCompleted += OnWebViewNavigationCompleted;
// Notifica che WebView pronta
_webViewInitCompletionSource?.TrySetResult(true);
// Verifica immediata se c' gi un cookie
await CheckAndImportCookieIfAvailable();
}
else
{
Log("[ERROR] CoreWebView2 null dopo init", LogLevel.Error);
_webViewInitCompletionSource?.TrySetResult(false);
}
}
catch (Exception ex)
{
Log($"[ERROR] Inizializzazione WebView2 fallita: {ex.Message}", LogLevel.Error);
_webViewInitCompletionSource?.TrySetResult(false);
}
}
/// <summary>
/// Verifica e importa cookie se disponibile
/// </summary>
private async Task CheckAndImportCookieIfAvailable()
{
try
{
// Aspetta che la pagina sia completamente caricata
await Task.Delay(1000);
var cookie = await GetCookieFromWebView();
if (!string.IsNullOrEmpty(cookie))
{
var currentSession = _sessionService?.GetCurrentSession();
// Importa solo se diverso da quello salvato
if (currentSession == null ||
string.IsNullOrEmpty(currentSession.CookieString) ||
!currentSession.CookieString.Contains(cookie))
{
Log("[BROWSER] Cookie rilevato nel browser - importazione automatica...", LogLevel.Info);
await AutoImportCookieFromWebView(cookie);
}
}
}
catch (Exception ex)
{
Log($"[WARN] Verifica cookie fallita: {ex.Message}", LogLevel.Warning);
}
}
/// <summary>
/// Aspetta che WebView sia inizializzata (con timeout)
/// </summary>
private async Task<bool> WaitForWebViewInitAsync(int timeoutSeconds = 60)
{
if (_isWebViewInitialized)
return true;
_webViewInitCompletionSource = new TaskCompletionSource<bool>();
// Timeout
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds));
var completedTask = await Task.WhenAny(_webViewInitCompletionSource.Task, timeoutTask);
if (completedTask == timeoutTask)
{
Log("[WARN] Timeout attesa inizializzazione WebView2", LogLevel.Warning);
return false;
}
return await _webViewInitCompletionSource.Task;
}
/// <summary>
/// Evento chiamato quando la navigazione nella WebView completata
/// Rileva automaticamente se l'utente ha effettuato il login
/// </summary>
private async void OnWebViewNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
try
{
if (!e.IsSuccess || EmbeddedWebView?.CoreWebView2 == null)
return;
var url = EmbeddedWebView.CoreWebView2.Source;
// Se l'utente sulla homepage di Bidoo (dopo login), verifica cookie
if (url.Contains("bidoo.com") && !url.Contains("login"))
{
// ? REFACTORED: Delega a CheckAndImportCookieIfAvailable
await CheckAndImportCookieIfAvailable();
}
}
catch { }
}
/// <summary>
/// Importa automaticamente il cookie dalla WebView senza conferma utente
/// </summary>
private async Task<bool> AutoImportCookieFromWebView(string cookieString)
{
try
{
// Valida e attiva il cookie usando SessionService
var result = await _sessionService.ValidateAndActivateSessionAsync(cookieString);
if (result.Success && result.Session != null)
{
// Salva automaticamente la sessione
_sessionService.SaveSession(result.Session);
// Aggiorna il banner
Dispatcher.Invoke(() =>
{
SetUserBanner(result.Session.Username, result.Session.RemainingBids);
});
return true;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Estrae il cookie __stattrb dalla WebView2
/// </summary>
/// <returns>Cookie completo o null se non trovato</returns>
private async Task<string?> GetCookieFromWebView()
{
try
{
if (EmbeddedWebView?.CoreWebView2 == null)
return null;
// Ottieni tutti i cookie di bidoo.com
var cookies = await EmbeddedWebView.CoreWebView2.CookieManager.GetCookiesAsync("https://it.bidoo.com");
if (cookies == null || cookies.Count == 0)
return null;
// Cerca il cookie __stattrb (cookie di sessione principale)
var stattrb = cookies.FirstOrDefault(c => c.Name == "__stattrb");
if (stattrb == null)
return null;
// Costruisci la stringa cookie completa con tutti i cookie necessari
var cookieStrings = cookies
.Where(c => !string.IsNullOrEmpty(c.Value))
.Select(c => $"{c.Name}={c.Value}")
.ToList();
return string.Join("; ", cookieStrings);
}
catch (Exception ex)
{
Log($"[WARN] Impossibile estrarre cookie da WebView: {ex.Message}", LogLevel.Warning);
return null;
}
}
/// <summary>
/// Importa il cookie dalla WebView e lo salva per l'uso nelle API
/// </summary>
public async Task<bool> ImportCookieFromWebView()
{
try
{
if (!_isWebViewInitialized || EmbeddedWebView?.CoreWebView2 == null)
{
Log("[WARN] Browser non inizializzato - attendi qualche secondo e riprova", LogLevel.Warning);
return false;
}
Log("[BROWSER] Estrazione cookie dal browser...", LogLevel.Info);
var cookieString = await GetCookieFromWebView();
if (string.IsNullOrEmpty(cookieString))
{
Log("[WARN] Nessun cookie trovato nel browser - assicurati di aver effettuato il login su bidoo.com", LogLevel.Warning);
return false;
}
// ? NOTA: Non aggiorna pi TextBox (rimossa) - direttamente alla validazione
// Valida e attiva il cookie usando SessionService
var result = await _sessionService.ValidateAndActivateSessionAsync(cookieString);
if (result.Success && result.Session != null)
{
// Salva automaticamente la sessione
_sessionService.SaveSession(result.Session);
// Aggiorna il banner
SetUserBanner(result.Session.Username, result.Session.RemainingBids);
Log($"[OK] Cookie importato e validato - Utente: {result.Session.Username}, Puntate: {result.Session.RemainingBids}", LogLevel.Success);
return true;
}
else
{
Log($"[ERRORE] Cookie importato ma non valido: {result.ErrorMessage}", LogLevel.Error);
return false;
}
}
catch (Exception ex)
{
Log($"[ERRORE] Importazione cookie: {ex.Message}", LogLevel.Error);
return false;
}
}
/// <summary>
/// Verifica se WebView2 pronta per l'uso
/// </summary>
public bool IsWebViewReady()
{
return _isWebViewInitialized && EmbeddedWebView?.CoreWebView2 != null;
}
}
}
+328
View File
@@ -0,0 +1,328 @@
using System;
using System.Threading;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// L'archivio delle osservazioni: un solo file SQLite con tutto ciò che si osserva e
/// si decide — aste, puntate, interrogazioni, reset, nostre puntate, decisioni del
/// motore, misure di rete, puntatori, sessioni, contabilità del rischio.
///
/// <para><b>Perché un database.</b> Prima c'erano un file JSON per asta (i dossier),
/// un file JSON con lo storico compatto, uno con le statistiche per prodotto: copie
/// parziali degli stessi fatti, da tenere allineate, e nessuna interrogabile. Qui ogni
/// fatto sta in una riga sola, con un indice, e una domanda come «quante puntate
/// manuali fra le 10 e le 13 sui buoni da 10 € sono rimaste senza risposta?» è una
/// riga di SQL invece di una lettura di otto gigabyte.</para>
///
/// <para>Il motore (coda di scrittura, letture in WAL) è in <see cref="SqliteDatabase"/>.
/// I dati d'esercizio e i registri stanno nell'altro file, <see cref="OperationalDatabase"/>.</para>
/// </summary>
public sealed class AuctionDatabase : SqliteDatabase
{
public const int SchemaVersion = 1;
private static readonly object InstanceSync = new();
private static AuctionDatabase? _instance;
/// <summary>L'archivio dell'applicazione, aperto al primo uso in <see cref="AppPaths.DatabaseFile"/>.</summary>
public static AuctionDatabase Instance
{
get
{
lock (InstanceSync)
{
if (_instance == null || _instance.IsDisposed)
_instance = Open(AppPaths.DatabaseFile);
return _instance;
}
}
}
/// <summary>Chiude l'archivio dell'applicazione svuotando la coda. All'uscita, o prima di spostarlo.</summary>
public static void CloseInstance()
{
lock (InstanceSync)
{
_instance?.Dispose();
_instance = null;
}
}
private long _nextDecisionId;
private long _auctionsVersion;
/// <summary>Cambia a ogni scrittura sulle aste: chi tiene una copia in memoria la confronta con questo.</summary>
public long AuctionsVersion => Interlocked.Read(ref _auctionsVersion);
private AuctionDatabase(string path) : base(path, SchemaV1, SchemaVersion, "AutoBidder.Database") { }
public static AuctionDatabase Open(string path) => new(path);
protected override void OnOpened(SqliteConnection writer)
{
// Gli id delle decisioni si assegnano qui, non da SQLite: chi registra una
// decisione deve poterla legare alla puntata senza aspettare il disco.
try { _nextDecisionId = writer.Query("SELECT COALESCE(MAX(decision_id), 0) AS n FROM bot_decisions")[0].Long("n"); }
catch { _nextDecisionId = 0; }
}
protected override void OnStatementExecuted(string sql)
{
if (sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
}
/// <summary>Il prossimo id per <c>bot_decisions</c>: unico, crescente, senza aspettare la scrittura.</summary>
public long NextDecisionId() => Interlocked.Increment(ref _nextDecisionId);
/// <summary>
/// Svuota le tabelle delle osservazioni e delle decisioni. Restano i prodotti
/// (portano il valore reale scelto dall'utente) e le sessioni.
/// </summary>
public void WipeObservations()
{
EnqueueBatch(new (string, object?[])[]
{
("DELETE FROM auctions", Array.Empty<object?>()),
("DELETE FROM bot_decisions", Array.Empty<object?>()),
("DELETE FROM network_metrics", Array.Empty<object?>()),
("DELETE FROM bidders", Array.Empty<object?>()),
("DELETE FROM risk_ledger", Array.Empty<object?>())
});
Flush();
Vacuum();
}
private const string SchemaV1 = """
CREATE TABLE IF NOT EXISTS meta(
key TEXT PRIMARY KEY,
value TEXT);
-- Un prodotto: ciò che le aste hanno in comune. market_price_est è il valore
-- reale V scelto dall'utente; retail_price_bidoo è il «Compralo Ora».
CREATE TABLE IF NOT EXISTS items(
item_id INTEGER PRIMARY KEY,
product_key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
category TEXT,
retail_price_bidoo REAL,
market_price_est REAL,
market_price_source TEXT,
shipping REAL,
first_seen_at TEXT NOT NULL,
updated_at TEXT NOT NULL);
-- Un'asta: una riga sola, aggiornata dall'apertura alla chiusura. Le colonne
-- del riepilogo restano NULL finché l'asta è in corso.
CREATE TABLE IF NOT EXISTS auctions(
auction_id TEXT PRIMARY KEY,
item_id INTEGER REFERENCES items(item_id),
product_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'normal',
status TEXT NOT NULL DEFAULT 'open',
me TEXT,
app_version TEXT,
added_at TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
opened_at TEXT NOT NULL,
closed_at TEXT,
end_time TEXT,
final_price REAL,
winner_user TEXT,
won_by_me INTEGER NOT NULL DEFAULT 0,
outcome TEXT,
final_status TEXT,
bid_increment REAL NOT NULL DEFAULT 0.01,
bid_fee REAL,
shipping REAL,
transaction_fee REAL,
buy_now_price REAL,
market_value REAL,
has_win_limit INTEGER NOT NULL DEFAULT 0,
win_limit TEXT,
my_bids INTEGER NOT NULL DEFAULT 0,
winner_bids_paid INTEGER,
winner_bids_free INTEGER,
resets INTEGER NOT NULL DEFAULT 0,
distinct_bidders INTEGER NOT NULL DEFAULT 0,
total_observed_bids INTEGER NOT NULL DEFAULT 0,
top_bidder_share REAL,
observed_from_start INTEGER NOT NULL DEFAULT 0,
observed_to_end INTEGER NOT NULL DEFAULT 0,
observed_minutes REAL,
avg_ping_ms REAL,
p95_ping_ms REAL,
polls INTEGER NOT NULL DEFAULT 0,
poll_errors INTEGER NOT NULL DEFAULT 0,
configured_lead_ms INTEGER,
lead_is_manual INTEGER NOT NULL DEFAULT 0,
timer_expired INTEGER NOT NULL DEFAULT 0,
successful_bids INTEGER NOT NULL DEFAULT 0,
failed_bids INTEGER NOT NULL DEFAULT 0,
price_velocity_per_minute REAL,
price_series_json TEXT,
bids_by_user_json TEXT,
config_json TEXT,
abandon_reason TEXT,
learned_at TEXT);
CREATE INDEX IF NOT EXISTS ix_auctions_end ON auctions(end_time);
CREATE INDEX IF NOT EXISTS ix_auctions_product ON auctions(product_key);
CREATE INDEX IF NOT EXISTS ix_auctions_status ON auctions(status);
-- Ogni puntata di ogni utente, come la riporta Bidoo. Il prezzo dopo la
-- puntata la identifica: ogni puntata alza il prezzo di un centesimo esatto.
CREATE TABLE IF NOT EXISTS bids(
bid_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
username TEXT NOT NULL,
price_after REAL NOT NULL,
bid_type TEXT NOT NULL DEFAULT '—',
is_mine INTEGER NOT NULL DEFAULT 0,
server_ts INTEGER,
seen_t REAL,
local_ts TEXT,
timer_before REAL,
rtt_ms INTEGER,
clock_offset_ms INTEGER,
UNIQUE(auction_id, price_after));
CREATE INDEX IF NOT EXISTS ix_bids_user ON bids(username);
CREATE INDEX IF NOT EXISTS ix_bids_server_ts ON bids(auction_id, server_ts);
-- Le interrogazioni, già coalescenti: una riga quando cambia qualcosa, e
-- comunque una al secondo perché il ping resti misurabile.
CREATE TABLE IF NOT EXISTS polls(
poll_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
price REAL,
timer REAL,
status TEXT,
last_bidder TEXT,
is_mine INTEGER NOT NULL DEFAULT 0,
ping_ms INTEGER,
expiry_unix INTEGER,
server_unix INTEGER,
clock_offset_ms INTEGER);
CREATE INDEX IF NOT EXISTS ix_polls_auction ON polls(auction_id, t);
-- Un reset del timer: chiude un ciclo, di cui si sa fin dove è sceso il
-- cronometro e quanto è durato.
CREATE TABLE IF NOT EXISTS resets(
reset_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
reset_count INTEGER,
price REAL,
bidder TEXT,
min_timer REAL,
cycle_seconds REAL);
CREATE INDEX IF NOT EXISTS ix_resets_auction ON resets(auction_id, t);
-- Le nostre puntate, con anticipo voluto e ottenuto: la coppia su cui si
-- tara il modello di latenza.
CREATE TABLE IF NOT EXISTS my_bids(
id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
price REAL,
planned_lead_ms INTEGER,
actual_lead_ms REAL,
ping_ms INTEGER,
rtt_ms INTEGER,
success INTEGER NOT NULL DEFAULT 0,
error TEXT,
bids_used INTEGER,
remaining_bids INTEGER,
decision_id INTEGER);
CREATE INDEX IF NOT EXISTS ix_my_bids_auction ON my_bids(auction_id, t);
-- Il registro dell'asta (strategie, avvisi, errori), riga per riga.
CREATE TABLE IF NOT EXISTS auction_log(
id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
level TEXT,
category TEXT,
msg TEXT);
CREATE INDEX IF NOT EXISTS ix_log_auction ON auction_log(auction_id, t);
-- Ogni decisione del motore, in Osserva (shadow) come in Attiva (live):
-- cosa avrebbe fatto o ha fatto, con che stima e per quale motivo.
CREATE TABLE IF NOT EXISTS bot_decisions(
decision_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL,
ts TEXT NOT NULL,
t REAL,
price REAL,
timer REAL,
action TEXT NOT NULL,
p_win REAL,
ev_estimate REAL,
model_version TEXT,
policy TEXT,
regime TEXT,
mode TEXT NOT NULL,
executed INTEGER NOT NULL DEFAULT 0,
outcome TEXT,
reason_detail TEXT,
state_json TEXT,
context_key TEXT,
alt_policy TEXT,
alt_action TEXT,
alt_p REAL);
CREATE INDEX IF NOT EXISTS ix_decisions_auction ON bot_decisions(auction_id);
CREATE INDEX IF NOT EXISTS ix_decisions_ts ON bot_decisions(ts);
-- Misure di rete aggregate a finestre di dieci secondi.
CREATE TABLE IF NOT EXISTS network_metrics(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
rtt_ms INTEGER,
jitter_ms INTEGER,
p95_ms INTEGER,
clock_offset_ms INTEGER,
samples INTEGER,
source TEXT);
CREATE INDEX IF NOT EXISTS ix_network_ts ON network_metrics(ts);
-- Chi punta: aggregato asta per asta alla chiusura.
CREATE TABLE IF NOT EXISTS bidders(
username TEXT PRIMARY KEY,
first_seen TEXT,
last_seen TEXT,
n_auctions INTEGER NOT NULL DEFAULT 0,
n_bids INTEGER NOT NULL DEFAULT 0,
n_auto INTEGER NOT NULL DEFAULT 0,
n_manual INTEGER NOT NULL DEFAULT 0,
win_count INTEGER NOT NULL DEFAULT 0,
aggressiveness_score REAL,
profile_class TEXT);
CREATE TABLE IF NOT EXISTS sessions(
session_id INTEGER PRIMARY KEY,
started_at TEXT NOT NULL,
ended_at TEXT,
username TEXT,
app_version TEXT,
notes TEXT);
-- Uscite ed entrate in euro, per il gestore del rischio: ogni puntata
-- riuscita è una riga, ogni vincita è una riga.
CREATE TABLE IF NOT EXISTS risk_ledger(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
kind TEXT NOT NULL,
amount_euro REAL NOT NULL,
auction_id TEXT,
note TEXT);
CREATE INDEX IF NOT EXISTS ix_ledger_ts ON risk_ledger(ts);
""";
}
}
+521
View File
@@ -0,0 +1,521 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Registra nel database tutto ciò che succede a un'asta mentre la si segue: ogni
/// interrogazione, ogni puntata di ogni utente con l'ora al millisecondo, ogni reset
/// con la profondità del ciclo, ogni nostra puntata con anticipo voluto ed effettivo,
/// ogni riga di registro, e alla fine il riepilogo.
///
/// <para>È l'erede del dossier JSON per asta, con la stessa superficie: il motore
/// chiama gli stessi metodi di prima e non sa che dietro c'è SQLite. Cambia dove
/// finiscono i dati — una riga per tabella invece di una riga per file — e il fatto
/// che da lì si possano interrogare.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo
/// non espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li
/// si raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su
/// cui il modello impara.</para>
///
/// <para>Nessuna scrittura tocca il disco sul thread che segue l'asta: ogni riga va
/// nella coda del database e prosegue.</para>
/// </summary>
public sealed class AuctionRecorder
{
private static readonly ConcurrentDictionary<string, AuctionRecorder> Open = new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Il nostro nome utente su Bidoo. Lo imposta il motore appena conosce la
/// sessione; finisce sull'asta, così chi rilegge sa quali puntate erano nostre.
/// </summary>
public static volatile string CurrentUsername = "";
private static AuctionDatabase Db => AuctionDatabase.Instance;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
// Coalescenza dei poll: si scrive quando cambia qualcosa, e comunque una volta
// al secondo. Misurato sui dossier: l'88,8% delle interrogazioni ha la stessa
// faccia della precedente.
private string? _lastPollSignature;
private DateTime _lastPollWrittenAt = DateTime.MinValue;
// Del ciclo in corso: fin dove è sceso il cronometro e quando è cominciato.
private double _cycleMinTimer = double.MaxValue;
private double _cycleStartedElapsed = -1;
private double _completedCycleMinTimer = double.NaN;
private double _completedCycleSeconds = double.NaN;
private double _lastPollPrice = double.NaN;
private const int MaxPingSamples = 50_000;
public string AuctionId { get; }
/// <summary>Righe scritte finora per quest'asta.</summary>
public long EventCount => _events;
private AuctionRecorder(string auctionId, DateTime startedAt)
{
AuctionId = auctionId;
_startedAt = startedAt;
}
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) la registrazione di quest'asta. Riaprendo l'applicazione
/// mentre l'asta è ancora viva si <b>riprende la stessa riga</b>: un'asta seguita
/// a cavallo di un riavvio deve restare una storia sola.
/// </summary>
public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.RecordAuctions) return null;
if (string.IsNullOrEmpty(auction.AuctionId)) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>La registrazione già aperta per quest'asta, se c'è.</summary>
public static AuctionRecorder? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var r) ? r : null;
/// <summary>Fa confluire nel database tutte le righe di registro delle aste. Una volta all'avvio.</summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// L'asta è stata tolta dal monitor prima della fine: la riga resta, marcata
/// come interrotta, perché una serie di prezzi che si ferma a metà sembra
/// un'asta finita a quel prezzo.
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var rec)) return;
rec.LogRow("info", "State", $"asta tolta dal monitor prima della conclusione: {reason}");
Db.Enqueue(
"UPDATE auctions SET status = CASE WHEN outcome IS NULL THEN 'abandoned' ELSE status END, " +
"abandon_reason = ?2, closed_at = COALESCE(closed_at, ?3), polls = ?4 WHERE auction_id = ?1",
auctionId, reason, AuctionDatabase.Now(), rec._polls);
}
/// <summary>Aspetta che tutto ciò che è in coda sia sul disco.</summary>
public static void FlushAll() => Db.Flush();
/// <summary>
/// Una decisione del motore, in Osserva (shadow) come in Attiva (live): cosa
/// avrebbe fatto o ha fatto, con che stima e per quale motivo. Restituisce l'id,
/// che la puntata porta con sé. L'esito si scrive alla chiusura dell'asta.
/// </summary>
public static long RecordDecision(
AuctionInfo auction, AuctionState state, string action, double? pWin, double? ev,
string? reason, string mode, string? regime, string? policy, double? leadMs,
string? contextKey = null, string? altAction = null, double? altP = null)
{
var id = Db.NextDecisionId();
var rec = For(auction.AuctionId);
var stateJson = JsonSerializer.Serialize(new
{
price = state.Price,
timer = Math.Round(state.Timer, 2),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
pingMs = state.PollingLatencyMs,
bidsUsed = auction.BidsUsedOnThisAuction,
resets = auction.ResetCount,
leadMs = leadMs ?? auction.BidBeforeDeadlineMs,
duel = auction.AutoBidDuelDetected,
autoResponses = auction.AutoResponsesInARow,
recentBidders = auction.SnapshotRecentBids(10).Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count()
}, Json);
Db.Enqueue(
"INSERT INTO bot_decisions(decision_id, auction_id, ts, t, price, timer, action, p_win, ev_estimate, model_version, policy, regime, mode, reason_detail, state_json, context_key, alt_policy, alt_action, alt_p) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
id, auction.AuctionId, AuctionDatabase.Now(), rec?.Elapsed(), state.Price, Math.Round(state.Timer, 2), action,
pWin, ev, Ml.LearningService.ModelVersion, policy, regime, mode, reason, stateJson,
contextKey, altAction == null ? null : "bandit", altAction, altP);
return id;
}
public static int OpenCount => Open.Count;
private static AuctionRecorder Create(AuctionInfo auction, AppSettings settings)
{
var rec = new AuctionRecorder(auction.AuctionId, DateTime.Now);
var now = AuctionDatabase.Now();
var productKey = ProductKeyHelper.GenerateProductKey(auction.Name);
var config = JsonSerializer.Serialize(new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
bidLeadIsManual = auction.BidLeadIsManual,
adaptiveLead = settings.AdaptiveLeadEnabled,
leadMinMs = settings.LeadMinMs,
leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
rawPollsIncluded = settings.RecordPolls
}, Json);
var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername;
Db.EnqueueBatch(new (string, object?[])[]
{
("INSERT INTO items(product_key, title, retail_price_bidoo, shipping, first_seen_at, updated_at) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?5) " +
"ON CONFLICT(product_key) DO UPDATE SET " +
" title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END, " +
" retail_price_bidoo = COALESCE(excluded.retail_price_bidoo, retail_price_bidoo), " +
" shipping = COALESCE(excluded.shipping, shipping), updated_at = excluded.updated_at",
new object?[] { productKey, auction.Name ?? "", auction.BuyNowPrice, auction.ShippingCost, now }),
("INSERT INTO auctions(auction_id, item_id, product_key, name, url, status, me, app_version, " +
" added_at, first_seen_at, opened_at, bid_fee, shipping, buy_now_price, market_value, has_win_limit, win_limit, " +
" configured_lead_ms, lead_is_manual, config_json) " +
"VALUES(?1, (SELECT item_id FROM items WHERE product_key = ?2), ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?8, ?9, ?10, ?11, " +
" (SELECT market_price_est FROM items WHERE product_key = ?2), ?12, ?13, ?14, ?15, ?16) " +
"ON CONFLICT(auction_id) DO UPDATE SET " +
" me = COALESCE(excluded.me, me), app_version = excluded.app_version, " +
" buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
" shipping = COALESCE(excluded.shipping, shipping), " +
" configured_lead_ms = excluded.configured_lead_ms, lead_is_manual = excluded.lead_is_manual, " +
" config_json = excluded.config_json",
new object?[]
{
auction.AuctionId, productKey, auction.Name ?? "", auction.OriginalUrl ?? "", me, AppInfo.Version,
AuctionDatabase.Iso(auction.AddedAt), now, auction.BidCost, auction.ShippingCost, auction.BuyNowPrice,
auction.HasWinLimit, auction.WinLimitDescription,
auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
auction.BidLeadIsManual, config
})
});
rec.LogRow("info", "State", "registrazione aperta");
return rec;
}
/// <summary>
/// Chiude la registrazione con il riepilogo. Da qui in avanti la riga non cambia
/// più: è ciò che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var rec)) return;
rec.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
var series = JsonSerializer.Serialize(detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }), Json);
var byUser = JsonSerializer.Serialize(detail.BidsByUser, Json);
Db.EnqueueBatch(new (string, object?[])[]
{
("UPDATE auctions SET status = 'closed', closed_at = ?2, end_time = ?3, outcome = ?4, won_by_me = ?5, winner_user = ?6, " +
" final_price = ?7, final_status = ?8, buy_now_price = COALESCE(?9, buy_now_price), shipping = COALESCE(?10, shipping), " +
" bid_fee = ?11, my_bids = ?12, resets = ?13, distinct_bidders = ?14, total_observed_bids = ?15, top_bidder_share = ?16, " +
" observed_from_start = ?17, observed_to_end = ?18, observed_minutes = ?19, first_seen_at = ?20, " +
" avg_ping_ms = ?21, p95_ping_ms = ?22, polls = ?23, poll_errors = ?24, configured_lead_ms = ?25, lead_is_manual = ?26, " +
" timer_expired = ?27, successful_bids = ?28, failed_bids = ?29, price_velocity_per_minute = ?30, " +
" price_series_json = ?31, bids_by_user_json = ?32 " +
"WHERE auction_id = ?1",
new object?[]
{
AuctionId, AuctionDatabase.Now(), AuctionDatabase.Iso(detail.EndedAt), detail.Outcome, detail.WonByMe, detail.Winner,
detail.FinalPrice, finalState?.Status.ToString(), detail.BuyNowPrice, detail.ShippingCost,
auction.BidCost, detail.MyBids, detail.Resets, detail.DistinctBidders, detail.TotalObservedBids, Math.Round(detail.TopBidderShare, 4),
auction.ObservedFromStart, auction.ObservedToEnd, Math.Round(detail.ObservedMinutes, 2), AuctionDatabase.Iso(detail.FirstSeenAt),
pings.Avg, pings.P95, _polls, detail.PollErrors, detail.ConfiguredLeadMs, auction.BidLeadIsManual,
auction.TimerExpiredCount, auction.SuccessfulBidCount, auction.FailedBidCount, Math.Round(detail.PriceVelocityPerMinute, 4),
series, byUser
}),
// Chi ha puntato in quest'asta: aggregato una volta, alla chiusura.
("INSERT INTO bidders(username, first_seen, last_seen, n_auctions, n_bids, n_auto, n_manual, win_count) " +
"SELECT username, MIN(local_ts), MAX(local_ts), 1, COUNT(*), " +
" SUM(CASE WHEN bid_type = 'Auto' THEN 1 ELSE 0 END), SUM(CASE WHEN bid_type = 'Manuale' THEN 1 ELSE 0 END), 0 " +
"FROM bids WHERE auction_id = ?1 GROUP BY username " +
"ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, first_seen = COALESCE(first_seen, excluded.first_seen), " +
" n_auctions = n_auctions + 1, n_bids = n_bids + excluded.n_bids, n_auto = n_auto + excluded.n_auto, n_manual = n_manual + excluded.n_manual",
new object?[] { AuctionId }),
("UPDATE bidders SET win_count = win_count + 1 WHERE username = ?1 AND ?1 != ''",
new object?[] { detail.Winner ?? "" }),
("UPDATE items SET retail_price_bidoo = COALESCE(?2, retail_price_bidoo), shipping = COALESCE(?3, shipping), updated_at = ?4 " +
"WHERE product_key = ?1",
new object?[] { detail.ProductKey, detail.BuyNowPrice, detail.ShippingCost, AuctionDatabase.Now() }),
// L'esito di ogni decisione, ora che si sa come è finita. Per una puntata
// partita davvero il prezzo dopo di lei era price + 0,01; per una
// decisione shadow o un NO-OP il prezzo è quello del momento. Se il prezzo
// finale non l'ha superato, nessuno ha più puntato: quella sarebbe stata
// (o è stata) la puntata vincente.
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price + 0.01 >= ?2 - 0.005 THEN 'won' ELSE 'answered' END " +
"WHERE auction_id = ?1 AND executed = 1 AND action = 'BID' AND (outcome IS NULL OR outcome = 'sent')",
new object?[] { AuctionId, detail.FinalPrice }),
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price >= ?2 - 0.005 THEN (CASE WHEN action = 'BID' THEN 'would_win' ELSE 'missed' END) " +
" ELSE (CASE WHEN action = 'BID' THEN 'would_be_answered' ELSE 'right_skip' END) END " +
"WHERE auction_id = ?1 AND executed = 0 AND outcome IS NULL",
new object?[] { AuctionId, detail.FinalPrice })
});
_events++;
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>Una singola interrogazione a Bidoo.</summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
var now = Elapsed();
if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice)
{
_completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer;
_completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN;
_cycleMinTimer = double.MaxValue;
_cycleStartedElapsed = now;
}
else if (_cycleStartedElapsed < 0)
{
_cycleStartedElapsed = now;
}
_lastPollPrice = state.Price;
if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer;
}
NetworkSampler.Note(state);
if (!includeRaw) return;
var signature = string.Concat(
state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|',
(int)state.Timer, '|', state.IsMyBid ? '1' : '0');
var wall = DateTime.Now;
if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000)
return;
_lastPollSignature = signature;
_lastPollWrittenAt = wall;
Write("INSERT INTO polls(auction_id, t, local_ts, price, timer, status, last_bidder, is_mine, ping_ms, expiry_unix, server_unix, clock_offset_ms) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
AuctionId, Elapsed(), AuctionDatabase.Now(), state.Price, Math.Round(state.Timer, 2), state.Status.ToString(),
string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder, state.IsMyBid, state.PollingLatencyMs,
state.ExpiryUnixSeconds, state.ServerUnixSeconds, NetworkSampler.ClockOffsetMs(state));
}
/// <summary>Una puntata altrui (o nostra, vista dallo storico), come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
double? timerBefore;
lock (_sync) timerBefore = _cycleMinTimer == double.MaxValue ? null : Math.Round(_cycleMinTimer, 2);
// Il tipo dichiarato dal server vince su quello ignoto di una riga già presente;
// una puntata ricostruita da un cambio di prezzo non sovrascrive mai un tipo vero.
Write("INSERT INTO bids(auction_id, username, price_after, bid_type, is_mine, server_ts, seen_t, local_ts, timer_before) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " +
"ON CONFLICT(auction_id, price_after) DO UPDATE SET " +
" bid_type = CASE WHEN excluded.bid_type != '—' THEN excluded.bid_type ELSE bid_type END, " +
" server_ts = COALESCE(NULLIF(excluded.server_ts, 0), server_ts), " +
" is_mine = MAX(is_mine, excluded.is_mine), " +
" username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END",
AuctionId, entry.Username ?? "", price, entry.BidType ?? BidHistoryEntry.TipoSconosciuto, entry.IsMyBid,
entry.Timestamp > 0 ? entry.Timestamp : (object?)null, Elapsed(), AuctionDatabase.Now(), timerBefore);
}
/// <summary>Una mia puntata, con tutto ciò che serve a giudicarla.</summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids,
int rttMs = 0,
long? decisionId = null)
{
Write("INSERT INTO my_bids(auction_id, t, local_ts, price, planned_lead_ms, actual_lead_ms, ping_ms, rtt_ms, success, error, bids_used, remaining_bids, decision_id) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
AuctionId, Elapsed(), AuctionDatabase.Now(), price, plannedLeadMs, Math.Round(actualLeadMs, 1), pingMs, rttMs,
success, error, bidsUsed, remainingBids, decisionId);
if (decisionId is { } id)
Db.Enqueue("UPDATE bot_decisions SET executed = 1, outcome = ?2 WHERE decision_id = ?1", id, success ? "sent" : "failed:" + (error ?? "?"));
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
double? minTimer, cycleSeconds;
lock (_sync)
{
minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2);
cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2);
_completedCycleMinTimer = double.NaN;
_completedCycleSeconds = double.NaN;
}
Write("INSERT INTO resets(auction_id, t, local_ts, reset_count, price, bidder, min_timer, cycle_seconds) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
AuctionId, Elapsed(), AuctionDatabase.Now(), resetCount, price, bidder, minTimer, cycleSeconds);
}
/// <summary>Una riga del registro dell'asta.</summary>
public void Log(string level, string category, string message)
{
// Le righe "[RESET #n]" ripetono l'evento reset che sta già nella sua tabella.
if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
LogRow(level, category, message ?? "");
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to) => LogRow("info", "State", $"{from} → {to}");
private void LogRow(string level, string category, string message)
{
Write("INSERT INTO auction_log(auction_id, t, local_ts, level, category, msg) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
AuctionId, Elapsed(), AuctionDatabase.Now(), level, category, message);
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(string sql, params object?[] args)
{
if (_closed) return;
Db.Enqueue(sql, args);
_events++;
}
internal double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private (int Count, double Avg, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0);
Array.Sort(copy);
return (copy.Length, Math.Round(copy.Average(), 1), copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
}
/// <summary>
/// Misure di rete aggregate: una riga ogni dieci secondi con mediana, p95, jitter e
/// scarto dell'orologio, su tutte le interrogazioni di tutte le aste. Una riga per
/// poll sarebbero milioni di righe uguali; una ogni dieci secondi racconta la stessa
/// storia in un decimillesimo dello spazio.
/// </summary>
public static class NetworkSampler
{
private static readonly object Sync = new();
private static readonly List<int> _pings = new(512);
private static long _windowStartedTicks = Environment.TickCount64;
private static int _minOffset = int.MaxValue;
private const int WindowMs = 10_000;
/// <summary>
/// Scarto fra il nostro orologio e quello del server, in ms, per questa risposta.
/// Bidoo dichiara i secondi interi, quindi il valore è quantizzato: il minimo su
/// una finestra converge al confine reale del secondo.
/// </summary>
public static int ClockOffsetMs(AuctionState state)
{
if (state.ServerUnixSeconds <= 0) return 0;
var localMs = new DateTimeOffset(state.SnapshotTime.Kind == DateTimeKind.Utc ? state.SnapshotTime : state.SnapshotTime.ToUniversalTime())
.ToUnixTimeMilliseconds() - state.PollingLatencyMs / 2;
return (int)Math.Clamp(localMs - state.ServerUnixSeconds * 1000L, int.MinValue / 2, int.MaxValue / 2);
}
public static void Note(AuctionState state)
{
if (state.PollingLatencyMs <= 0) return;
List<int>? flush = null;
int offset = 0;
lock (Sync)
{
_pings.Add(state.PollingLatencyMs);
var o = ClockOffsetMs(state);
if (state.ServerUnixSeconds > 0 && o < _minOffset) _minOffset = o;
if (Environment.TickCount64 - _windowStartedTicks >= WindowMs)
{
flush = new List<int>(_pings);
offset = _minOffset == int.MaxValue ? 0 : _minOffset;
_pings.Clear();
_minOffset = int.MaxValue;
_windowStartedTicks = Environment.TickCount64;
}
}
if (flush == null || flush.Count == 0) return;
flush.Sort();
var p50 = flush[flush.Count / 2];
var p95 = flush[Math.Min(flush.Count - 1, (int)(flush.Count * 0.95))];
AuctionDatabase.Instance.Enqueue(
"INSERT INTO network_metrics(ts, rtt_ms, jitter_ms, p95_ms, clock_offset_ms, samples, source) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'poll')",
AuctionDatabase.Now(), p50, p95 - p50, p95, offset, flush.Count);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Il database d'esercizio: tutto ciò che l'applicazione scrive per sé e non per
/// l'analisi. Sta in un file a parte (<c>esercizio.sqlite</c>) accanto a quello delle
/// osservazioni, così l'archivio delle aste resta un archivio e questo resta piccolo.
///
/// <para><b>Documenti.</b> Le aste nel monitor, i prodotti seguiti, le statistiche per
/// prodotto, le promozioni riscattate, il modello appreso, il profilo, la latenza:
/// ognuno era un file JSON riscritto per intero a ogni modifica. Qui sono righe di una
/// tabella <c>documents</c>, chiave → JSON: la scrittura è atomica (una riga in una
/// transazione, niente file a metà), e c'è un solo file da salvare.</para>
///
/// <para><b>Registri.</b> Il registro applicativo e quello del riscatto puntate sono
/// tabelle: una riga per evento, con data e livello. Si interrogano, si esportano in
/// testo dalle Impostazioni, e si cancellano per età senza girare cartelle.</para>
/// </summary>
public sealed class OperationalDatabase : SqliteDatabase
{
public const int SchemaVersion = 1;
private static readonly object InstanceSync = new();
private static OperationalDatabase? _instance;
public static OperationalDatabase Instance
{
get
{
lock (InstanceSync)
{
if (_instance == null || _instance.IsDisposed)
_instance = Open(AppPaths.OperationalDatabaseFile);
return _instance;
}
}
}
public static void CloseInstance()
{
lock (InstanceSync)
{
_instance?.Dispose();
_instance = null;
}
}
/// <summary>Vero quando l'istanza è aperta e pronta: chi scrive nei registri lo controlla prima.</summary>
public static bool IsOpen
{
get { lock (InstanceSync) return _instance != null && !_instance.IsDisposed; }
}
private OperationalDatabase(string path) : base(path, SchemaV1, SchemaVersion, "AutoBidder.Esercizio") { }
public static OperationalDatabase Open(string path) => new(path);
private const string SchemaV1 = """
-- Un documento JSON per chiave: aste nel monitor, prodotti seguiti,
-- statistiche per prodotto, promozioni riscattate, modelli appresi.
CREATE TABLE IF NOT EXISTS documents(
key TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_at TEXT NOT NULL);
-- Il registro applicativo: una riga per evento.
CREATE TABLE IF NOT EXISTS app_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_app_log_ts ON app_log(ts);
-- Il registro del riscatto puntate.
CREATE TABLE IF NOT EXISTS freebids_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_freebids_log_ts ON freebids_log(ts);
""";
// ── Documenti ────────────────────────────────────────────────────
/// <summary>Il JSON del documento, o null se non c'è.</summary>
public string? GetDocument(string key)
{
Flush();
return ScalarString("SELECT json FROM documents WHERE key = ?1", key);
}
/// <summary>
/// Il documento, oppure — se manca — il contenuto del vecchio file JSON indicato,
/// che viene importato una volta e da allora vive qui. Il file resta dov'è.
/// </summary>
public string? GetDocumentOrImport(string key, string? legacyFile)
{
var json = GetDocument(key);
if (json != null) return json;
try
{
if (string.IsNullOrEmpty(legacyFile) || !File.Exists(legacyFile)) return null;
json = File.ReadAllText(legacyFile);
if (string.IsNullOrWhiteSpace(json)) return null;
SetDocument(key, json);
Log($"[DATABASE] Importato {System.IO.Path.GetFileName(legacyFile)} come documento «{key}»");
return json;
}
catch (Exception ex)
{
Log($"[DATABASE] Importazione di {legacyFile} non riuscita: {ex.Message}");
return null;
}
}
public void SetDocument(string key, string json)
{
Enqueue("INSERT INTO documents(key, json, updated_at) VALUES(?1, ?2, ?3) " +
"ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at",
key, json, Now());
}
public void DeleteDocument(string key) => Enqueue("DELETE FROM documents WHERE key = ?1", key);
/// <summary>Cancella tutti i documenti con quel prefisso (es. «ml/»).</summary>
public void DeleteDocuments(string prefix) => Enqueue("DELETE FROM documents WHERE key LIKE ?1", prefix + "%");
public IReadOnlyList<(string Key, long Bytes, DateTime UpdatedAt)> Documents()
{
Flush();
return Query("SELECT key, LENGTH(json) AS bytes, updated_at FROM documents ORDER BY key")
.Select(r => (r.Str("key"), r.Long("bytes"), r.Date("updated_at") ?? DateTime.MinValue))
.ToList();
}
// ── Registri ─────────────────────────────────────────────────────
public const string AppLog = "app_log";
public const string FreeBidsLog = "freebids_log";
public void WriteLog(string table, string level, string message)
{
if (table != AppLog && table != FreeBidsLog) return;
Enqueue($"INSERT INTO {table}(ts, level, message) VALUES(?1, ?2, ?3)", Now(), level, message ?? "");
}
/// <summary>Cancella le righe più vecchie di tanti giorni. Restituisce quante.</summary>
public int PurgeLogs(int days)
{
if (days <= 0) return 0;
var cutoff = Iso(DateTime.Now.AddDays(-days));
var removed = 0;
foreach (var table in new[] { AppLog, FreeBidsLog })
{
removed += (int)ScalarLong($"SELECT COUNT(*) AS n FROM {table} WHERE ts < ?1", cutoff);
Enqueue($"DELETE FROM {table} WHERE ts < ?1", cutoff);
}
Flush();
return removed;
}
/// <summary>Le ultime righe di un registro, dalla più recente.</summary>
public List<(DateTime Ts, string Level, string Message)> ReadLog(string table, int max = 2000, DateTime? since = null)
{
Flush();
var rows = since is { } s
? Query($"SELECT ts, level, message FROM {table} WHERE ts >= ?1 ORDER BY id DESC LIMIT ?2", Iso(s), max)
: Query($"SELECT ts, level, message FROM {table} ORDER BY id DESC LIMIT ?1", max);
return rows.Select(r => (r.Date("ts") ?? DateTime.MinValue, r.Str("level"), r.Str("message"))).ToList();
}
/// <summary>Scrive un registro in un file di testo leggibile (una riga per evento, ora locale).</summary>
public int ExportLog(string table, string destination, int days = 0)
{
Flush();
var rows = days > 0
? Query($"SELECT ts, level, message FROM {table} WHERE ts >= ?1 ORDER BY id", Iso(DateTime.Now.AddDays(-days)))
: Query($"SELECT ts, level, message FROM {table} ORDER BY id");
var sb = new StringBuilder();
foreach (var r in rows)
{
var ts = r.Date("ts")?.ToLocalTime() ?? DateTime.MinValue;
sb.Append(ts.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append(" [").Append(r.Str("level")).Append("] ").AppendLine(r.Str("message"));
}
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
File.WriteAllText(destination, sb.ToString(), new UTF8Encoding(true));
return rows.Count;
}
}
}
+495
View File
@@ -0,0 +1,495 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace AutoBidder.Data
{
/// <summary>
/// SQLite senza librerie: si chiama direttamente <c>winsqlite3.dll</c>, la copia di
/// SQLite che Windows 10 e 11 tengono in <c>System32</c> per i propri componenti.
///
/// <para><b>Perché così.</b> L'applicazione non deve dipendere da pacchetti esterni.
/// Un provider ADO.NET porterebbe con sé quattro assembly e una DLL nativa; qui ci
/// sono le quindici funzioni C che servono davvero, avvolte in due classi che non
/// fanno niente di più di quello che il programma usa: aprire, preparare, legare
/// parametri, scorrere righe, chiudere.</para>
///
/// <para><b>Fili.</b> Ogni connessione è usata da un thread alla volta: il database
/// scrive da un thread suo e legge sotto un lucchetto (vedi <see cref="AuctionDatabase"/>).
/// La connessione è comunque aperta in modalità serializzata, per non dipendere dalla
/// disciplina del chiamante.</para>
/// </summary>
internal static class SqliteNative
{
private const string Lib = "winsqlite3.dll";
public const int Ok = 0;
public const int Row = 100;
public const int Done = 101;
public const int Busy = 5;
public const int Locked = 6;
public const int OpenReadWrite = 0x2;
public const int OpenCreate = 0x4;
public const int OpenFullMutex = 0x10000;
public const int TypeInteger = 1;
public const int TypeFloat = 2;
public const int TypeText = 3;
public const int TypeBlob = 4;
public const int TypeNull = 5;
/// <summary>SQLITE_TRANSIENT: SQLite copia il testo legato prima che il buffer venga liberato.</summary>
public static readonly IntPtr Transient = new(-1);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_open_v2(byte[] filename, out IntPtr db, int flags, IntPtr vfs);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_close_v2(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_prepare_v2(IntPtr db, byte[] sql, int nByte, out IntPtr stmt, out IntPtr tail);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_step(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_reset(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_clear_bindings(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_finalize(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_int64(IntPtr stmt, int index, long value);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_double(IntPtr stmt, int index, double value);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_null(IntPtr stmt, int index);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_text(IntPtr stmt, int index, byte[] text, int nBytes, IntPtr destructor);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_count(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_type(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern long sqlite3_column_int64(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern double sqlite3_column_double(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_text(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_bytes(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_name(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_errmsg(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern long sqlite3_last_insert_rowid(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_changes(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_busy_timeout(IntPtr db, int ms);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_libversion();
public static byte[] Utf8Z(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
var z = new byte[bytes.Length + 1];
Buffer.BlockCopy(bytes, 0, z, 0, bytes.Length);
return z;
}
public static string? Utf8(IntPtr p) => p == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(p);
}
public sealed class SqliteException : Exception
{
public int Code { get; }
public SqliteException(int code, string message) : base(message) => Code = code;
}
/// <summary>Una connessione. Da usare da un thread alla volta.</summary>
public sealed class SqliteConnection : IDisposable
{
private IntPtr _db;
private readonly Dictionary<string, SqliteStatement> _cache = new(StringComparer.Ordinal);
public string Path { get; }
public static string LibraryVersion
{
get
{
try { return SqliteNative.Utf8(SqliteNative.sqlite3_libversion()) ?? "?"; }
catch (DllNotFoundException) { return "winsqlite3.dll non trovata"; }
}
}
public SqliteConnection(string path)
{
Path = path;
var rc = SqliteNative.sqlite3_open_v2(
SqliteNative.Utf8Z(path), out _db,
SqliteNative.OpenReadWrite | SqliteNative.OpenCreate | SqliteNative.OpenFullMutex,
IntPtr.Zero);
if (rc != SqliteNative.Ok)
{
var msg = _db != IntPtr.Zero ? ErrorMessage() : $"codice {rc}";
if (_db != IntPtr.Zero) SqliteNative.sqlite3_close_v2(_db);
_db = IntPtr.Zero;
throw new SqliteException(rc, $"apertura di {path} non riuscita: {msg}");
}
// Un altro processo (o l'altra connessione) può tenere il file per qualche
// millisecondo: si aspetta invece di fallire.
SqliteNative.sqlite3_busy_timeout(_db, 5000);
}
internal IntPtr Handle => _db;
public string ErrorMessage() => SqliteNative.Utf8(SqliteNative.sqlite3_errmsg(_db)) ?? "errore sconosciuto";
public long LastInsertRowId => SqliteNative.sqlite3_last_insert_rowid(_db);
public int Changes => SqliteNative.sqlite3_changes(_db);
/// <summary>Esegue un'istruzione senza risultati (o ne scarta le righe).</summary>
public void Execute(string sql, params object?[] args)
{
using var stmt = Prepare(sql);
stmt.Bind(args);
stmt.RunToEnd();
}
/// <summary>Esegue più istruzioni separate da punto e virgola, senza parametri.</summary>
public void ExecuteScript(string script)
{
var bytes = SqliteNative.Utf8Z(script);
var offset = 0;
while (offset < bytes.Length - 1)
{
var remaining = new byte[bytes.Length - offset];
Buffer.BlockCopy(bytes, offset, remaining, 0, remaining.Length);
var rc = SqliteNative.sqlite3_prepare_v2(_db, remaining, -1, out var stmt, out var tail);
if (rc != SqliteNative.Ok) throw new SqliteException(rc, ErrorMessage());
// Il puntatore alla coda restituito da SQLite riguarda una copia nativa
// del buffer di cui non abbiamo la base: si ricava quanto è stato
// consumato scandendo fino al primo ';' fuori da apici e commenti, che è
// esattamente dove SQLite si è fermato.
var consumed = IndexAfterStatement(remaining);
if (stmt != IntPtr.Zero)
{
using var s = new SqliteStatement(this, stmt, "");
s.RunToEnd();
}
offset += Math.Max(1, consumed);
}
}
/// <summary>Fino al primo ';' fuori da apici e commenti, incluso.</summary>
private static int IndexAfterStatement(byte[] bytes)
{
var inQuote = false;
var inLineComment = false;
for (var i = 0; i < bytes.Length - 1; i++)
{
var c = (char)bytes[i];
if (inLineComment) { if (c == '\n') inLineComment = false; continue; }
if (c == '\'') inQuote = !inQuote;
else if (!inQuote && c == '-' && i + 1 < bytes.Length && bytes[i + 1] == '-') inLineComment = true;
else if (!inQuote && c == ';') return i + 1;
}
return bytes.Length - 1;
}
/// <summary>Prepara un'istruzione nuova, non condivisa. Chi la chiede la libera.</summary>
public SqliteStatement Prepare(string sql)
{
var rc = SqliteNative.sqlite3_prepare_v2(_db, SqliteNative.Utf8Z(sql), -1, out var stmt, out _);
if (rc != SqliteNative.Ok || stmt == IntPtr.Zero)
throw new SqliteException(rc, $"{ErrorMessage()} — SQL: {sql}");
return new SqliteStatement(this, stmt, sql);
}
/// <summary>
/// Un'istruzione preparata e riusata: le stesse dieci frasi vengono eseguite
/// migliaia di volte, e prepararle ogni volta costerebbe più dell'esecuzione.
/// Viene azzerata prima di ogni uso.
/// </summary>
public SqliteStatement Cached(string sql)
{
if (!_cache.TryGetValue(sql, out var stmt))
{
stmt = Prepare(sql);
stmt.Shared = true;
_cache[sql] = stmt;
}
stmt.Reset();
return stmt;
}
public List<SqliteRow> Query(string sql, params object?[] args)
{
var stmt = Cached(sql);
stmt.Bind(args);
return stmt.ReadAll();
}
public void Dispose()
{
foreach (var s in _cache.Values) s.Shared = false;
foreach (var s in _cache.Values) s.Dispose();
_cache.Clear();
if (_db != IntPtr.Zero)
{
SqliteNative.sqlite3_close_v2(_db);
_db = IntPtr.Zero;
}
}
}
/// <summary>Un'istruzione preparata.</summary>
public sealed class SqliteStatement : IDisposable
{
private readonly SqliteConnection _conn;
private IntPtr _stmt;
private string[]? _columns;
public string Sql { get; }
/// <summary>Appartiene alla cache della connessione: Dispose non la libera.</summary>
internal bool Shared { get; set; }
internal SqliteStatement(SqliteConnection conn, IntPtr stmt, string sql)
{
_conn = conn;
_stmt = stmt;
Sql = sql;
}
public void Reset()
{
SqliteNative.sqlite3_reset(_stmt);
SqliteNative.sqlite3_clear_bindings(_stmt);
}
/// <summary>Lega i parametri posizionali (?1, ?2, …) nell'ordine dato.</summary>
public void Bind(object?[] args)
{
for (var i = 0; i < args.Length; i++)
{
var idx = i + 1;
var rc = args[i] switch
{
null => SqliteNative.sqlite3_bind_null(_stmt, idx),
string s => BindText(idx, s),
bool b => SqliteNative.sqlite3_bind_int64(_stmt, idx, b ? 1 : 0),
int n => SqliteNative.sqlite3_bind_int64(_stmt, idx, n),
long l => SqliteNative.sqlite3_bind_int64(_stmt, idx, l),
double d => double.IsNaN(d) || double.IsInfinity(d)
? SqliteNative.sqlite3_bind_null(_stmt, idx)
: SqliteNative.sqlite3_bind_double(_stmt, idx, d),
float f => SqliteNative.sqlite3_bind_double(_stmt, idx, f),
decimal m => SqliteNative.sqlite3_bind_double(_stmt, idx, (double)m),
DateTime dt => BindText(idx, AuctionDatabase.Iso(dt)),
DateTimeOffset dto => BindText(idx, dto.UtcDateTime.ToString("o")),
Enum e => BindText(idx, e.ToString()),
_ => BindText(idx, args[i]!.ToString() ?? "")
};
if (rc != SqliteNative.Ok)
throw new SqliteException(rc, $"{_conn.ErrorMessage()} — parametro {idx} di: {Sql}");
}
}
private int BindText(int idx, string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return SqliteNative.sqlite3_bind_text(_stmt, idx, bytes, bytes.Length, SqliteNative.Transient);
}
/// <summary>Avanza di una riga. False alla fine.</summary>
public bool Step()
{
var rc = SqliteNative.sqlite3_step(_stmt);
if (rc == SqliteNative.Row) return true;
if (rc == SqliteNative.Done) return false;
throw new SqliteException(rc, $"{_conn.ErrorMessage()} — SQL: {Sql}");
}
public void RunToEnd()
{
while (Step()) { }
}
public string[] Columns
{
get
{
if (_columns != null) return _columns;
var n = SqliteNative.sqlite3_column_count(_stmt);
_columns = new string[n];
for (var i = 0; i < n; i++)
_columns[i] = SqliteNative.Utf8(SqliteNative.sqlite3_column_name(_stmt, i)) ?? $"c{i}";
return _columns;
}
}
public object? Value(int col)
{
switch (SqliteNative.sqlite3_column_type(_stmt, col))
{
case SqliteNative.TypeInteger: return SqliteNative.sqlite3_column_int64(_stmt, col);
case SqliteNative.TypeFloat: return SqliteNative.sqlite3_column_double(_stmt, col);
case SqliteNative.TypeText:
var p = SqliteNative.sqlite3_column_text(_stmt, col);
var n = SqliteNative.sqlite3_column_bytes(_stmt, col);
return p == IntPtr.Zero ? "" : Marshal.PtrToStringUTF8(p, n);
default: return null;
}
}
public List<SqliteRow> ReadAll()
{
var rows = new List<SqliteRow>();
var columns = Columns;
var index = SqliteRow.IndexFor(columns);
while (Step())
{
var values = new object?[columns.Length];
for (var i = 0; i < values.Length; i++) values[i] = Value(i);
rows.Add(new SqliteRow(index, values));
}
return rows;
}
public void Dispose()
{
if (Shared) return;
if (_stmt != IntPtr.Zero)
{
SqliteNative.sqlite3_finalize(_stmt);
_stmt = IntPtr.Zero;
}
}
}
/// <summary>Una riga letta, con accesso per nome di colonna e conversioni tolleranti.</summary>
public sealed class SqliteRow
{
private readonly Dictionary<string, int> _index;
private readonly object?[] _values;
private static readonly Dictionary<string, Dictionary<string, int>> IndexCache = new(StringComparer.Ordinal);
internal static Dictionary<string, int> IndexFor(string[] columns)
{
var key = string.Join("|", columns);
lock (IndexCache)
{
if (!IndexCache.TryGetValue(key, out var idx))
{
idx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < columns.Length; i++) idx[columns[i]] = i;
IndexCache[key] = idx;
}
return idx;
}
}
internal SqliteRow(Dictionary<string, int> index, object?[] values)
{
_index = index;
_values = values;
}
public object? this[string column] => _index.TryGetValue(column, out var i) ? _values[i] : null;
/// <summary>Valore per posizione: per i risultati scalari.</summary>
public object? At(int i) => i >= 0 && i < _values.Length ? _values[i] : null;
public bool Has(string column) => _index.ContainsKey(column);
public string Str(string column) => this[column]?.ToString() ?? "";
public string? StrOrNull(string column) => this[column] as string;
public long Long(string column) => this[column] switch
{
long l => l,
double d => (long)d,
string s when long.TryParse(s, out var v) => v,
_ => 0
};
public int Int(string column) => (int)Long(column);
public bool Bool(string column) => Long(column) != 0;
public double Dbl(string column) => this[column] switch
{
double d => d,
long l => l,
string s when double.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => 0
};
public double? DblOrNull(string column) => this[column] == null ? null : Dbl(column);
public int? IntOrNull(string column) => this[column] == null ? null : Int(column);
public long? LongOrNull(string column) => this[column] == null ? null : Long(column);
/// <summary>Data in UTC, o null. Le date sono scritte in ISO 8601 UTC.</summary>
public DateTime? Date(string column)
{
var s = this[column] as string;
if (string.IsNullOrEmpty(s)) return null;
if (!DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind, out var d))
return null;
return d.Kind switch
{
DateTimeKind.Utc => d,
DateTimeKind.Local => d.ToUniversalTime(),
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc) // scritto senza fuso: era UTC
};
}
}
}
+408
View File
@@ -0,0 +1,408 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
namespace AutoBidder.Data
{
/// <summary>
/// Il motore comune dei due database dell'applicazione: un file SQLite, una coda di
/// scrittura svuotata a blocchi da un thread dedicato, una connessione di lettura a
/// parte con giornale WAL.
///
/// <para><b>Come scrive.</b> Mai sul thread che chiama. Ogni scrittura finisce in coda
/// e il thread del database la esegue in una transazione ogni qualche centinaio di
/// righe: SQLite fa migliaia di inserimenti al secondo così, e nessuno sul percorso
/// della puntata aspetta il disco. Chi deve leggere ciò che ha appena scritto chiama
/// <see cref="Flush"/>.</para>
///
/// <para><b>Come legge.</b> Su una seconda connessione, sotto un lucchetto. Con il
/// giornale WAL le letture non bloccano la scrittura e viceversa.</para>
/// </summary>
public abstract class SqliteDatabase : IDisposable
{
/// <summary>Righe di registro (errori di scrittura, migrazioni): chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
protected static void Log(string message) => OnLog?.Invoke(message);
private readonly SqliteConnection _writer;
private readonly SqliteConnection _reader;
private readonly object _readSync = new();
private abstract class Pending { public long Seq; }
private sealed class Statement : Pending
{
public string Sql = "";
public object?[] Args = Array.Empty<object?>();
}
private sealed class Batch : Pending
{
public List<(string Sql, object?[] Args)> Ops = new();
}
private sealed class Custom : Pending
{
public Action<SqliteConnection> Action = _ => { };
public Exception? Error;
public ManualResetEventSlim Done = new(false);
}
private readonly Queue<Pending> _queue = new();
private readonly object _queueSync = new();
private long _lastEnqueued;
private long _lastCommitted;
private readonly Thread _writerThread;
private volatile bool _disposed;
private volatile bool _stop;
private long _writesCommitted;
private long _writesFailed;
public long WritesCommitted => Interlocked.Read(ref _writesCommitted);
public long WritesFailed => Interlocked.Read(ref _writesFailed);
public int QueueLength { get { lock (_queueSync) return _queue.Count; } }
public bool IsDisposed => _disposed;
public string Path { get; }
public static string LibraryVersion => SqliteConnection.LibraryVersion;
protected SqliteDatabase(string path, string schemaScript, int schemaVersion, string threadName)
{
Path = path;
var folder = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
_writer = new SqliteConnection(path);
Configure(_writer);
Migrate(_writer, schemaScript, schemaVersion);
OnOpened(_writer);
_reader = new SqliteConnection(path);
Configure(_reader);
_writerThread = new Thread(WriterLoop)
{
Name = threadName,
IsBackground = true,
Priority = ThreadPriority.BelowNormal
};
_writerThread.Start();
}
/// <summary>Chiamato con la connessione di scrittura, dopo lo schema, prima del thread.</summary>
protected virtual void OnOpened(SqliteConnection writer) { }
/// <summary>Chiamato dal thread di scrittura per ogni istruzione eseguita.</summary>
protected virtual void OnStatementExecuted(string sql) { }
private static void Configure(SqliteConnection c)
{
c.Execute("PRAGMA journal_mode=WAL");
c.Execute("PRAGMA synchronous=NORMAL");
c.Execute("PRAGMA foreign_keys=ON");
c.Execute("PRAGMA temp_store=MEMORY");
}
private void Migrate(SqliteConnection c, string schema, int target)
{
var version = (int)c.Query("PRAGMA user_version")[0].Long("user_version");
if (version >= target) return;
c.ExecuteScript(schema);
c.Execute($"PRAGMA user_version={target}");
Log($"[DATABASE] Schema versione {target} in {c.Path} — SQLite {LibraryVersion}");
}
// ── Scrittura ────────────────────────────────────────────────────
public void Enqueue(string sql, params object?[] args)
{
if (_disposed) return;
Push(new Statement { Sql = sql, Args = args });
}
public void EnqueueBatch(IEnumerable<(string Sql, object?[] Args)> ops)
{
if (_disposed) return;
var batch = new Batch();
batch.Ops.AddRange(ops);
if (batch.Ops.Count == 0) return;
Push(batch);
}
public void RunOnWriter(Action<SqliteConnection> action, int timeoutMs = 60000)
{
if (_disposed) throw new ObjectDisposedException(GetType().Name);
var custom = new Custom { Action = action };
Push(custom);
if (!custom.Done.Wait(timeoutMs)) throw new TimeoutException("il database non ha risposto in tempo");
if (custom.Error != null) throw custom.Error;
}
public void ExecuteNow(string sql, params object?[] args)
{
Enqueue(sql, args);
Flush();
}
private void Push(Pending p)
{
lock (_queueSync)
{
p.Seq = ++_lastEnqueued;
_queue.Enqueue(p);
Monitor.PulseAll(_queueSync);
}
}
public bool Flush(int timeoutMs = 15000)
{
long target;
lock (_queueSync) target = _lastEnqueued;
var deadline = Environment.TickCount64 + timeoutMs;
lock (_queueSync)
{
while (Interlocked.Read(ref _lastCommitted) < target)
{
var left = deadline - Environment.TickCount64;
if (left <= 0 || _disposed) return Interlocked.Read(ref _lastCommitted) >= target;
Monitor.Wait(_queueSync, (int)Math.Min(left, 500));
}
}
return true;
}
private void WriterLoop()
{
var buffer = new List<Pending>(512);
while (!_stop)
{
buffer.Clear();
lock (_queueSync)
{
while (_queue.Count == 0 && !_stop) Monitor.Wait(_queueSync, 250);
while (_queue.Count > 0 && buffer.Count < 2000) buffer.Add(_queue.Dequeue());
}
if (buffer.Count == 0) continue;
var i = 0;
while (i < buffer.Count)
{
switch (buffer[i])
{
case Statement:
var j = i;
while (j < buffer.Count && buffer[j] is Statement) j++;
RunStatements(buffer, i, j);
i = j;
break;
case Batch b:
RunBatch(b);
i++;
break;
case Custom c:
try { c.Action(_writer); }
catch (Exception ex) { c.Error = ex; }
finally { MarkCommitted(c.Seq); c.Done.Set(); }
i++;
break;
default:
i++;
break;
}
}
}
}
private void RunStatements(List<Pending> buffer, int from, int to)
{
long lastSeq = 0;
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
for (var k = from; k < to; k++)
{
var s = (Statement)buffer[k];
lastSeq = s.Seq;
try
{
var stmt = _writer.Cached(s.Sql);
stmt.Bind(s.Args);
stmt.RunToEnd();
Interlocked.Increment(ref _writesCommitted);
OnStatementExecuted(s.Sql);
}
catch (SqliteException ex)
{
Interlocked.Increment(ref _writesFailed);
Log($"[DATABASE] Scrittura rifiutata: {ex.Message}");
}
}
_writer.Execute("COMMIT");
inTx = false;
}
catch (Exception ex)
{
Log($"[DATABASE] Transazione non riuscita: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, to - from);
}
finally
{
MarkCommitted(Math.Max(lastSeq, buffer[to - 1].Seq));
}
}
private void RunBatch(Batch b)
{
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
foreach (var (sql, args) in b.Ops)
{
var stmt = _writer.Cached(sql);
stmt.Bind(args);
stmt.RunToEnd();
OnStatementExecuted(sql);
}
_writer.Execute("COMMIT");
inTx = false;
Interlocked.Add(ref _writesCommitted, b.Ops.Count);
}
catch (Exception ex)
{
Log($"[DATABASE] Blocco annullato: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, b.Ops.Count);
}
finally
{
MarkCommitted(b.Seq);
}
}
private void MarkCommitted(long seq)
{
lock (_queueSync)
{
if (seq > _lastCommitted) Interlocked.Exchange(ref _lastCommitted, seq);
Monitor.PulseAll(_queueSync);
}
}
// ── Lettura ──────────────────────────────────────────────────────
public List<SqliteRow> Query(string sql, params object?[] args)
{
if (_disposed) return new List<SqliteRow>();
lock (_readSync) return _reader.Query(sql, args);
}
public SqliteRow? QueryOne(string sql, params object?[] args)
{
var rows = Query(sql, args);
return rows.Count > 0 ? rows[0] : null;
}
public long ScalarLong(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => (long)d, string s when long.TryParse(s, out var x) => x, _ => 0 };
}
public double ScalarDouble(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => d, string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) => x, _ => 0 };
}
public string? ScalarString(string sql, params object?[] args) => QueryOne(sql, args)?.At(0)?.ToString();
public long Count(string table) => ScalarLong($"SELECT COUNT(*) AS n FROM {table}");
// ── Manutenzione ─────────────────────────────────────────────────
/// <summary>Copia coerente dell'intero archivio in un altro file (VACUUM INTO).</summary>
public void BackupTo(string destination)
{
Flush();
if (File.Exists(destination)) File.Delete(destination);
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
RunOnWriter(c => c.Execute("VACUUM INTO ?1", destination));
}
/// <summary>Byte occupati sul disco (file principale più giornale WAL).</summary>
public long SizeBytes
{
get
{
long total = 0;
foreach (var f in new[] { Path, Path + "-wal" })
{
try { if (File.Exists(f)) total += new FileInfo(f).Length; } catch { }
}
return total;
}
}
/// <summary>Compatta il file. Fuori da ogni transazione, sul thread del database.</summary>
public void Vacuum()
{
try { RunOnWriter(c => c.Execute("VACUUM")); } catch { /* lo spazio si recupera la prossima volta */ }
}
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Le date si scrivono in ISO 8601 UTC: ordinano bene e non dipendono dal fuso.</summary>
public static string Iso(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Local).ToUniversalTime()
};
return utc.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
public static string Now() => Iso(DateTime.UtcNow);
public void Dispose()
{
if (_disposed) return;
Flush(5000);
_stop = true;
lock (_queueSync) Monitor.PulseAll(_queueSync);
try { _writerThread.Join(3000); } catch { }
_disposed = true;
lock (_readSync) _reader.Dispose();
_writer.Dispose();
}
}
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<Window x:Class="AutoBidder.Dialogs.AddAuctionSimpleDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:AutoBidder.Controls"
Title="Aggiungi Asta" Height="320" Width="700"
Background="{DynamicResource Brush.Bg}" Foreground="{DynamicResource Brush.Text}"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize">
<Border Background="{DynamicResource Brush.Bg}" CornerRadius="8" Padding="16" Margin="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Inserire URL dell'asta (uno o pi&#x00F9;)" Foreground="{DynamicResource Brush.Text}" FontSize="14" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Text="Puoi aggiungere pi&#x00F9; link separandoli con 'a capo', 'spazio' o ';'" Foreground="{DynamicResource Brush.TextMuted}" FontSize="12" Margin="0,0,0,10" />
<TextBox x:Name="AuctionUrlBox" Grid.Row="2" MinWidth="560" Margin="0,0,0,8"
Background="{DynamicResource Brush.Bg}" Foreground="{DynamicResource Brush.Info}" BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1"
Padding="8" FontSize="13" ToolTip="Inserisci uno o pi&#x00F9; URL/ID dell'asta. Separali con a capo, spazio o ';'"
AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Height="160" />
<controls:SimpleToolbar Grid.Row="3" Margin="0,8,0,0">
<controls:SimpleToolbar.RightContent>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="OK" Width="110" Margin="6" Padding="10,8"
Style="{StaticResource SmallButtonStyle}" Background="{DynamicResource Brush.Success}" Foreground="{DynamicResource Brush.TextOnAccent}" Click="OkButton_Click" />
<Button Content="Annulla" Width="110" Margin="6" Padding="10,8"
Style="{StaticResource SmallButtonStyle}" Background="{DynamicResource Brush.SurfaceAlt}" Foreground="{DynamicResource Brush.Text}" Click="CancelButton_Click" />
</StackPanel>
</controls:SimpleToolbar.RightContent>
</controls:SimpleToolbar>
</Grid>
</Border>
</Window>
@@ -0,0 +1,35 @@
using System.Windows;
namespace AutoBidder.Dialogs
{
public partial class AddAuctionSimpleDialog : Window
{
public string AuctionId { get; private set; } = string.Empty;
public AddAuctionSimpleDialog()
{
InitializeComponent();
}
private void OkButton_Click(object sender, RoutedEventArgs e)
{
var text = AuctionUrlBox.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
MessageBox.Show("Inserisci almeno un URL o ID dell'asta.", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// Return the raw text (may contain multiple entries); caller will parse it
AuctionId = text.Trim();
DialogResult = true;
Close();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<Window x:Class="AutoBidder.Dialogs.ProgressDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Operazione in corso"
Width="480" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False" ResizeMode="NoResize"
Background="{DynamicResource Brush.Bg}">
<StackPanel Margin="22,18">
<TextBlock x:Name="TitleText"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock x:Name="MessageText" Style="{StaticResource Hint}" Margin="0,6,0,14"
TextWrapping="Wrap"/>
<ProgressBar x:Name="Bar" Height="6" Minimum="0" Maximum="100" IsIndeterminate="True"/>
<Grid Margin="0,10,0,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="CountText" Style="{StaticResource Hint}" Text=""/>
<TextBlock x:Name="ElapsedText" Style="{StaticResource Hint}" Margin="12,0,0,0" Text=""/>
</StackPanel>
<Button x:Name="CancelButton" Content="Annulla" HorizontalAlignment="Right"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Padding="18,5" Click="CancelButton_Click"/>
</Grid>
</StackPanel>
</Window>
+144
View File
@@ -0,0 +1,144 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using AutoBidder.Utilities;
namespace AutoBidder.Dialogs
{
/// <summary>
/// La finestra di avanzamento delle operazioni lunghe: titolo, una riga che dice a
/// che punto si è, la barra, il conto fatto/totale, il tempo passato e Annulla.
///
/// <para>Si usa da <see cref="RunAsync{T}"/>: si passa il lavoro da fare, che riceve
/// un <see cref="IProgress{T}"/> e un token di annullamento; la finestra si apre, segue
/// l'avanzamento e si chiude da sola. In modalità «modale» la finestra principale
/// resta disabilitata finché il lavoro non finisce; altrimenti si può continuare a
/// usare l'applicazione (le aste in corso non si fermano mai, in nessun caso).</para>
/// </summary>
public partial class ProgressDialog : Window
{
private readonly CancellationTokenSource _cts = new();
private readonly Stopwatch _elapsed = Stopwatch.StartNew();
private readonly DispatcherTimer _clock;
private bool _finished;
public CancellationToken Token => _cts.Token;
public ProgressDialog(string title, string message, bool cancellable)
{
InitializeComponent();
Title = title;
TitleText.Text = title;
MessageText.Text = message;
CancelButton.Visibility = cancellable ? Visibility.Visible : Visibility.Collapsed;
_clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_clock.Tick += (_, _) => ElapsedText.Text = _elapsed.Elapsed.TotalSeconds < 60
? $"{_elapsed.Elapsed.TotalSeconds:N0} s"
: $"{(int)_elapsed.Elapsed.TotalMinutes} min {_elapsed.Elapsed.Seconds:00} s";
_clock.Start();
}
/// <summary>Aggiorna barra e testi. Va chiamato sul thread dell'interfaccia.</summary>
public void Report(ProgressStep step)
{
if (_finished) return;
if (step.Total > 0)
{
Bar.IsIndeterminate = false;
Bar.Maximum = step.Total;
Bar.Value = Math.Clamp(step.Done, 0, step.Total);
CountText.Text = $"{step.Done:N0} / {step.Total:N0}";
}
else
{
Bar.IsIndeterminate = true;
CountText.Text = step.Done > 0 ? step.Done.ToString("N0") : "";
}
if (!string.IsNullOrEmpty(step.Message)) MessageText.Text = step.Message;
}
private void CancelButton_Click(object sender, RoutedEventArgs e) => RequestCancel();
private void RequestCancel()
{
if (_cts.IsCancellationRequested) return;
_cts.Cancel();
CancelButton.IsEnabled = false;
MessageText.Text = "Annullamento in corso: aspetto che il passo in corso finisca…";
}
/// <summary>
/// La X della finestra non chiude niente a metà: chiede l'annullamento, se
/// l'operazione lo ammette, e la finestra se ne va quando il lavoro è finito.
/// </summary>
protected override void OnClosing(CancelEventArgs e)
{
if (!_finished)
{
e.Cancel = true;
if (CancelButton.Visibility == Visibility.Visible) RequestCancel();
}
base.OnClosing(e);
}
private void Finish()
{
_finished = true;
_clock.Stop();
Close();
}
/// <summary>
/// Esegue <paramref name="work"/> mostrando la finestra di avanzamento sopra
/// <paramref name="owner"/>. Restituisce l'esito: risultato, annullata, o errore.
/// </summary>
/// <param name="modal">Vero: la finestra principale è disabilitata finché non si finisce.</param>
/// <param name="cancellable">Vero: c'è il pulsante Annulla e il token viene annullato.</param>
public static async Task<ProgressOutcome<T>> RunAsync<T>(
Window? owner, string title, string message,
Func<IProgress<ProgressStep>, CancellationToken, Task<T>> work,
bool modal = true, bool cancellable = true)
{
var dialog = new ProgressDialog(title, message, cancellable);
if (owner != null && owner.IsLoaded) dialog.Owner = owner;
// Progress<T> cattura il contesto di sincronizzazione di chi lo crea: da qui,
// il thread dell'interfaccia. I rapporti arrivano già sul thread giusto.
var progress = new Progress<ProgressStep>(dialog.Report);
if (modal && owner != null) owner.IsEnabled = false;
dialog.Show();
try
{
var result = await work(progress, dialog.Token);
return dialog.Token.IsCancellationRequested
? ProgressOutcome<T>.CancelledByUser()
: ProgressOutcome<T>.Ok(result);
}
catch (OperationCanceledException)
{
return ProgressOutcome<T>.CancelledByUser();
}
catch (Exception ex)
{
return ProgressOutcome<T>.Failed(ex);
}
finally
{
if (modal && owner != null) owner.IsEnabled = true;
dialog.Finish();
owner?.Activate();
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<Window x:Class="AutoBidder.Dialogs.SessionDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Configura Sessione" Height="440" Width="700"
Background="{DynamicResource Brush.Bg}" Foreground="{DynamicResource Brush.Text}"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize">
<Border Background="{DynamicResource Brush.Bg}" CornerRadius="8" Padding="16" Margin="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Inserisci Cookie di sessione" FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource Brush.Success}" Margin="0,0,0,12" />
<TextBlock Grid.Row="1" Text="Cookie di sessione (copia dal menu delle Impostazioni/Applicazioni di Chrome)" Foreground="{DynamicResource Brush.Text}" FontSize="13" />
<TextBlock Grid.Row="2" Foreground="{DynamicResource Brush.TextMuted}" FontSize="12" TextWrapping="Wrap" Margin="0,4,0,10">
<Run Text="Come trovare i cookie di sessione in Chrome:" />
<LineBreak/>
<Run Text="1. Apri Chrome e premi F12 (Windows) o Cmd+Option+I (Mac) per aprire gli Strumenti per sviluppatori." />
<LineBreak/>
<Run Text="2. Vai alla scheda 'Application'." />
<LineBreak/>
<Run Text="3. Nel pannello a sinistra, espandi 'Storage' e seleziona 'Cookies'." />
<LineBreak/>
<Run Text="4. Scegli il sito desiderato e copia il valore del cookie di sessione." />
</TextBlock>
<TextBox x:Name="CookieBox" Grid.Row="3" MinWidth="320" MinHeight="120" MaxHeight="220" VerticalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.Bg}" Foreground="{DynamicResource Brush.Info}" BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" Padding="8" FontSize="14" AcceptsReturn="True" TextWrapping="Wrap" />
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button x:Name="OkButton" Content="OK" Width="110" Margin="6" Padding="10,8"
Style="{StaticResource SmallButtonStyle}" Background="{DynamicResource Brush.Success}" Foreground="{DynamicResource Brush.TextOnAccent}" Click="OkButton_Click" />
<Button x:Name="CancelButton" Content="Annulla" Width="110" Margin="6" Padding="10,8"
Style="{StaticResource SmallButtonStyle}" Background="{DynamicResource Brush.SurfaceAlt}" Foreground="{DynamicResource Brush.Text}" Click="CancelButton_Click" />
</StackPanel>
</Grid>
</Border>
</Window>
+33
View File
@@ -0,0 +1,33 @@
using System.Windows;
namespace AutoBidder.Dialogs
{
public partial class SessionDialog : Window
{
public string AuthToken { get; private set; } = string.Empty;
public SessionDialog(string existingToken = "", string existingUsername = "")
{
InitializeComponent();
CookieBox.Text = existingToken ?? string.Empty;
}
private void OkButton_Click(object sender, RoutedEventArgs e)
{
AuthToken = CookieBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(AuthToken))
{
MessageBox.Show("Cookie obbligatorio", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
DialogResult = true;
Close();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
}
-247
View File
@@ -1,247 +0,0 @@
using System.Windows;
using System.Windows.Controls;
namespace AutoBidder
{
/// <summary>
/// Dialog per inizializzare sessione Bidoo
/// Richiede: Auth Token + Username
/// </summary>
public class SessionDialog : Window
{
private readonly TextBox _tokenTextBox;
private readonly TextBox _usernameTextBox;
public string AuthToken => _tokenTextBox.Text.Trim();
public string Username => _usernameTextBox.Text.Trim();
public SessionDialog(string existingToken = "", string existingUsername = "")
{
Title = "Configura Sessione Bidoo";
Width = 680;
Height = 420;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
Background = System.Windows.Media.Brushes.Black;
Foreground = System.Windows.Media.Brushes.White;
var grid = new Grid { Margin = new Thickness(20) };
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(140) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(15) });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(40) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(20) });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var label1 = new TextBlock
{
Text = "Cookie __stattrb (F12 > Application > Cookies > __stattrb):",
FontWeight = FontWeights.SemiBold,
Foreground = System.Windows.Media.Brushes.White,
TextWrapping = TextWrapping.Wrap
};
Grid.SetRow(label1, 0);
_tokenTextBox = new TextBox
{
Text = existingToken,
Padding = new Thickness(10),
TextWrapping = TextWrapping.Wrap,
AcceptsReturn = false,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalContentAlignment = VerticalAlignment.Top,
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(15, 15, 15)),
Foreground = System.Windows.Media.Brushes.LightGray,
FontFamily = new System.Windows.Media.FontFamily("Consolas"),
FontSize = 11,
ToolTip = "Esempio: eyJVU0VSSUQiOiI2NzA3NjY0Ii..."
};
Grid.SetRow(_tokenTextBox, 2);
var label2 = new TextBlock
{
Text = "Username Bidoo:",
FontWeight = FontWeights.SemiBold,
Foreground = System.Windows.Media.Brushes.White
};
Grid.SetRow(label2, 4);
_usernameTextBox = new TextBox
{
Text = existingUsername,
Padding = new Thickness(10),
FontSize = 14,
VerticalContentAlignment = VerticalAlignment.Center,
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(15, 15, 15)),
Foreground = System.Windows.Media.Brushes.LightGray
};
Grid.SetRow(_usernameTextBox, 6);
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
Grid.SetRow(buttonPanel, 8);
var okButton = new Button
{
Content = "Conferma",
Width = 120,
Height = 40,
Margin = new Thickness(0, 0, 10, 0),
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0, 204, 102)),
Foreground = System.Windows.Media.Brushes.White,
FontWeight = FontWeights.Bold,
FontSize = 14
};
var cancelButton = new Button
{
Content = "Annulla",
Width = 120,
Height = 40,
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(204, 0, 0)),
Foreground = System.Windows.Media.Brushes.White,
FontWeight = FontWeights.Bold,
FontSize = 14
};
okButton.Click += (s, e) =>
{
if (string.IsNullOrWhiteSpace(AuthToken) || string.IsNullOrWhiteSpace(Username))
{
MessageBox.Show("Inserisci Token e Username!", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
DialogResult = true;
Close();
};
cancelButton.Click += (s, e) => { DialogResult = false; Close(); };
buttonPanel.Children.Add(okButton);
buttonPanel.Children.Add(cancelButton);
grid.Children.Add(label1);
grid.Children.Add(_tokenTextBox);
grid.Children.Add(label2);
grid.Children.Add(_usernameTextBox);
grid.Children.Add(buttonPanel);
Content = grid;
Loaded += (s, e) => _tokenTextBox.Focus();
}
}
/// <summary>
/// Dialog semplificato per aggiungere asta (ID o URL completo)
/// </summary>
public class AddAuctionSimpleDialog : Window
{
private readonly TextBox _auctionIdTextBox;
public string AuctionId => _auctionIdTextBox.Text.Trim();
public AddAuctionSimpleDialog()
{
Title = "Aggiungi Asta";
Width = 680;
Height = 280;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
Background = System.Windows.Media.Brushes.Black;
Foreground = System.Windows.Media.Brushes.White;
var grid = new Grid { Margin = new Thickness(20) };
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(50) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(20) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(50) });
var label = new TextBlock
{
Text = "URL Asta o ID:",
FontWeight = FontWeights.SemiBold,
Foreground = System.Windows.Media.Brushes.White
};
Grid.SetRow(label, 0);
_auctionIdTextBox = new TextBox
{
Text = "",
Padding = new Thickness(10),
FontSize = 13,
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(15, 15, 15)),
Foreground = System.Windows.Media.Brushes.LightGray,
VerticalContentAlignment = VerticalAlignment.Center
};
Grid.SetRow(_auctionIdTextBox, 2);
var hintLabel = new TextBlock
{
Text = "Esempio: https://it.bidoo.com/auction.php?a=Galaxy_S25_Ultra_256GB_81204324\nOppure: 81204324",
FontSize = 11,
Foreground = System.Windows.Media.Brushes.Gray,
TextWrapping = TextWrapping.Wrap
};
Grid.SetRow(hintLabel, 4);
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
Grid.SetRow(buttonPanel, 6);
var okButton = new Button
{
Content = "Aggiungi",
Width = 120,
Height = 40,
Margin = new Thickness(0, 0, 10, 0),
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0, 204, 102)),
Foreground = System.Windows.Media.Brushes.White,
FontWeight = FontWeights.Bold,
FontSize = 14
};
var cancelButton = new Button
{
Content = "Annulla",
Width = 120,
Height = 40,
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(204, 0, 0)),
Foreground = System.Windows.Media.Brushes.White,
FontWeight = FontWeights.Bold,
FontSize = 14
};
okButton.Click += (s, e) => { DialogResult = true; Close(); };
cancelButton.Click += (s, e) => { DialogResult = false; Close(); };
_auctionIdTextBox.KeyDown += (s, e) =>
{
if (e.Key == System.Windows.Input.Key.Enter)
{
DialogResult = true;
Close();
}
};
buttonPanel.Children.Add(okButton);
buttonPanel.Children.Add(cancelButton);
grid.Children.Add(label);
grid.Children.Add(_auctionIdTextBox);
grid.Children.Add(hintLabel);
grid.Children.Add(buttonPanel);
Content = grid;
Loaded += (s, e) => _auctionIdTextBox.Focus();
}
}
}
+138
View File
@@ -0,0 +1,138 @@
<Window x:Class="AutoBidder.Dialogs.StatsCleanupDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Pulizia dello storico"
Width="620" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="{DynamicResource Brush.Bg}">
<StackPanel Margin="22,18">
<TextBlock Text="Cosa considerare incompleto"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock Style="{StaticResource Hint}" Margin="0,6,0,14"
Text="Il conto si aggiorna mentre scegli. Niente viene toccato finché non premi Pulisci, e viene salvata una copia dello storico prima di procedere."/>
<CheckBox x:Name="ChkNoPrice" Margin="0,4" IsChecked="True"
Content="Aste senza prezzo finale"
ToolTip="Non dicono nulla e sporcano ogni media: il prezzo è il dato su cui si regge tutto il resto."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkBadBids" Margin="0,4" IsChecked="True"
Content="Aste con puntate del vincitore incoerenti col prezzo"
ToolTip="Ogni puntata alza il prezzo di un centesimo, quindi il prezzo in centesimi è il totale delle puntate dell'asta. Un vincitore che ne dichiara di più — o zero — porta un dato che non può essere vero."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkDuplicates" Margin="0,4" IsChecked="True"
Content="Doppioni con lo stesso identificativo (tiene il più recente)"
ToolTip="La stessa asta può essere registrata due volte: quando il motore ne vede la fine e quando viene tolta dal monitor. Si tiene la versione più recente."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkNoWinnerBids" Margin="0,4"
Content="Aste di cui non si conoscono le puntate del vincitore"
ToolTip="Severo: toglie tutte le aste su cui non si può calcolare il costo reale. Prima di usarlo, prova «Recupera puntate dei vincitori» nella scheda Storico — il dato si può quasi sempre riprendere dal server."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkNoWinner" Margin="0,4"
Content="Aste senza vincitore"
ToolTip="Aste scadute senza offerte, oppure registrate a metà."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkNoValue" Margin="0,4"
Content="Aste senza valore del prodotto"
ToolTip="Senza il «Compra Subito» non si può calcolare il risparmio, ma il prezzo e le puntate restano validi."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkObservedOnly" Margin="0,4"
Content="Aste solo osservate (senza mie puntate)"
ToolTip="Tiene le aste su cui hai puntato — sono le uniche con il costo reale dentro — e toglie tutte le altre. Utile per uno storico compatto delle tue partite."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkWithMyBids" Margin="0,4"
Content="Aste su cui ho puntato"
ToolTip="Il verso opposto: tiene solo il mercato. Un'asta in cui sei intervenuto ha un prezzo finale che hai contribuito a fare, e per stimare cosa succede senza di te va tolta."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkPriceRatio" VerticalAlignment="Center"
Content="Aste chiuse oltre il"
ToolTip="Una chiusura al 60% del valore su un buono da 100 € non descrive il mercato: descrive due persone che si sono incaponite. Tenerla sposta le medie e i limiti consigliati verso l'alto per tutti.&#10;&#10;Sul tuo storico la chiusura mediana sta fra il 5% e il 6% del valore, e tre aste su quattro chiudono sotto il 15%."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<TextBox x:Name="PriceRatioBox" Text="40" Width="50" Margin="8,0"
VerticalAlignment="Center" TextChanged="Option_Changed"/>
<TextBlock Text="% del valore del prodotto" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<!-- Per prodotto: i prodotti presenti nello storico, col numero di aste -->
<Expander x:Name="ProductsExpander" Margin="0,10,0,0" IsExpanded="False"
Foreground="{DynamicResource Brush.Text}">
<Expander.Header>
<TextBlock x:Name="ProductsHeader" Text="Per prodotto: nessuno scelto"
Foreground="{DynamicResource Brush.Text}"/>
</Expander.Header>
<ListBox x:Name="ProductsList" MaxHeight="170" Margin="0,6,0,0"
Background="{DynamicResource Brush.SurfaceAlt}"
BorderBrush="{DynamicResource Brush.Border}"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Label}"
IsChecked="{Binding Selected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Foreground="{DynamicResource Brush.Text}"
Checked="Option_Changed" Unchecked="Option_Changed"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Expander>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkCoverage" VerticalAlignment="Center"
Content="Aste seguite per meno del"
ToolTip="Toglie le aste agganciate a metà. Sono il caso peggiore: i numeri sono veri ma parziali, quindi sembrano buone e abbassano le medie senza dare segnali.&#10;&#10;Quanta asta si è vista si ricava dal prezzo finale, senza bisogno di alcun dato in più: ogni puntata vale un centesimo, quindi il prezzo in centesimi è il totale delle puntate dell'asta, e i reset osservati dicono quanti se ne sono visti.&#10;&#10;Sul tuo storico, su 5876 aste: al 75% ne resterebbero circa 78 su 100, al 90% circa 64, al 95% circa 54, al 99% circa 34."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<TextBox x:Name="CoverageBox" Text="90" Width="50" Margin="8,0"
VerticalAlignment="Center" TextChanged="Option_Changed"/>
<TextBlock Text="% della loro durata" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkOlderThan" VerticalAlignment="Center"
Content="Aste chiuse da più di"
ToolTip="Le aste vecchie descrivono un mercato che può essere cambiato."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<TextBox x:Name="DaysBox" Text="180" Width="60" Margin="8,0"
VerticalAlignment="Center" TextChanged="Option_Changed"/>
<TextBlock Text="giorni" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<Border Style="{StaticResource InfoBox}" Margin="0,16,0,0">
<StackPanel>
<TextBlock x:Name="SummaryText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="18"
Text="Calcolo…"/>
<TextBlock x:Name="DetailText" TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource Brush.TextMuted}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="17"/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button x:Name="CleanButton" Content="Pulisci" Padding="26,7" Margin="0,0,10,0"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="Clean_Click"/>
<Button Content="Annulla" Padding="26,7"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
IsCancel="True"/>
</StackPanel>
</StackPanel>
</Window>
+175
View File
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Dialogs
{
/// <summary>
/// Sceglie cosa considerare incompleto nello storico e lo toglie.
///
/// <para>Il conto si aggiorna a ogni spunta, <b>prima</b> di toccare qualunque cosa: una
/// pulizia è irreversibile, e l'unico modo di renderla una decisione informata è mostrare
/// quante righe cadrebbero e per quale motivo mentre si sceglie. La copia di sicurezza
/// prima di scrivere è la seconda rete: le statistiche sono mesi di raccolta che nessun
/// pulsante ha il diritto di far sparire per un clic distratto.</para>
/// </summary>
public partial class StatsCleanupDialog : Window
{
private readonly List<CompletedAuctionRecord> _records;
/// <summary>Una riga dell'elenco prodotti: chiave, etichetta con il conteggio, spunta.</summary>
private sealed class ProductRow
{
public string Key { get; init; } = "";
public string Label { get; init; } = "";
public bool Selected { get; set; }
}
private readonly List<ProductRow> _products;
public StatsCleanupDialog(List<CompletedAuctionRecord> records)
{
InitializeComponent();
_records = records ?? new List<CompletedAuctionRecord>();
// I prodotti presenti nello storico, dal più numeroso: è l'ordine in cui
// uno li cerca quando vuole fare spazio.
_products = _records
.Where(r => !string.IsNullOrWhiteSpace(r.ProductKey))
.GroupBy(r => r.ProductKey, StringComparer.OrdinalIgnoreCase)
.Select(g => new ProductRow
{
Key = g.Key,
Label = $"{g.Select(r => r.Name).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)) ?? g.Key} ({g.Count()})"
})
.OrderByDescending(p => p.Label.Length > 0 ? _records.Count(r => string.Equals(r.ProductKey, p.Key, StringComparison.OrdinalIgnoreCase)) : 0)
.ToList();
ProductsList.ItemsSource = _products;
Loaded += (_, _) => Refresh();
}
/// <summary>Righe rimosse, dopo la conferma. 0 se l'utente ha annullato.</summary>
public int RemovedCount { get; private set; }
/// <summary>Dove è finita la copia di sicurezza.</summary>
public string? BackupPath { get; private set; }
private StatsMaintenance.CleanupOptions ReadOptions()
{
var options = new StatsMaintenance.CleanupOptions
{
RemoveWithoutPrice = ChkNoPrice.IsChecked == true,
RemoveInconsistentBids = ChkBadBids.IsChecked == true,
RemoveDuplicates = ChkDuplicates.IsChecked == true,
RemoveWithoutWinnerBids = ChkNoWinnerBids.IsChecked == true,
RemoveWithoutWinner = ChkNoWinner.IsChecked == true,
RemoveWithoutValue = ChkNoValue.IsChecked == true
};
// Una soglia illeggibile o fuori scala non deve diventare "togli tutto":
// il filtro resta spento finché il numero non ha senso.
if (ChkCoverage.IsChecked == true &&
double.TryParse(CoverageBox.Text.Trim().Replace(',', '.'),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var percento) &&
percento is > 0 and <= 100)
{
options.MinObservedCoverage = percento / 100.0;
}
if (ChkOlderThan.IsChecked == true &&
int.TryParse(DaysBox.Text.Trim(), out var days) && days > 0)
{
options.OlderThan = DateTime.Now.AddDays(-days);
}
options.RemoveObservedOnly = ChkObservedOnly.IsChecked == true;
options.RemoveWithMyBids = ChkWithMyBids.IsChecked == true;
if (ChkPriceRatio.IsChecked == true &&
double.TryParse(PriceRatioBox.Text.Trim().Replace(',', '.'),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var ratio) &&
ratio > 0)
{
options.MaxPriceRatioPercent = ratio;
}
var scelti = _products.Where(p => p.Selected).Select(p => p.Key).ToList();
if (scelti.Count > 0)
options.RemoveProductKeys = new HashSet<string>(scelti, StringComparer.OrdinalIgnoreCase);
return options;
}
private void Option_Changed(object sender, RoutedEventArgs e) => Refresh();
private void Refresh()
{
if (SummaryText == null) return;
if (ProductsHeader != null)
{
var n = _products.Count(p => p.Selected);
ProductsHeader.Text = n == 0
? $"Per prodotto: nessuno scelto ({_products.Count} nello storico)"
: $"Per prodotto: {n} scelti";
}
var report = StatsMaintenance.Preview(_records, ReadOptions());
SummaryText.Text = report.Removed == 0
? $"Nessuna delle {report.Total} aste verrebbe tolta."
: $"Verrebbero tolte {report.Removed} aste su {report.Total}. " +
$"Ne resterebbero {report.Kept}.";
DetailText.Text = report.ByReason.Count == 0
? ""
: string.Join("\n", report.ByReason
.OrderByDescending(kv => kv.Value)
.Select(kv => $"• {kv.Value} {kv.Key}"));
CleanButton.IsEnabled = report.Removed > 0;
}
private void Clean_Click(object sender, RoutedEventArgs e)
{
var options = ReadOptions();
var report = StatsMaintenance.Preview(_records, options);
if (report.Removed == 0) return;
var answer = MessageBox.Show(this,
$"Tolgo {report.Removed} aste su {report.Total}.\n\n" +
"L'operazione non è reversibile, ma prima viene salvata una copia " +
"dello storico nella cartella dei backup.\n\nProcedo?",
"Pulizia dello storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
BackupPath = CompletedAuctionsStore.BackupNow();
// La stessa risposta usata per il conto: contare con una regola e
// cancellare con un'altra è il modo classico di far sparire dati che
// nessuno voleva perdere.
var kept = StatsMaintenance.Apply(_records, options);
CompletedAuctionsStore.ReplaceAll(kept);
RemovedCount = report.Removed;
DialogResult = true;
}
catch (Exception ex)
{
MessageBox.Show(this, $"Pulizia non riuscita: {ex.Message}",
"Pulizia dello storico", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
<Window x:Class="AutoBidder.Dialogs.WipeStatsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Azzera le statistiche"
Width="600" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="{DynamicResource Brush.Bg}">
<StackPanel Margin="22,18">
<TextBlock Text="Cosa azzerare"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock Style="{StaticResource Hint}" Margin="0,6,0,14"
Text="Accanto a ogni voce c'è quanto spazio occupa adesso. Niente viene toccato finché non premi Azzera. Il database viene copiato per intero nella cartella dei backup prima di essere svuotato; il resto no."/>
<CheckBox x:Name="ChkHistory" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Tutto ciò che il database ha osservato e deciso: aste, puntate di ogni utente, interrogazioni, reset, tue puntate, decisioni del motore, misure di rete, puntatori, contabilità del rischio. I prodotti con il loro valore reale restano. Prima viene fatta una copia completa del database nei backup."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkProducts" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="I totali per prodotto (aste viste, prezzi medi, puntate del vincitore). Le opzioni scelte nella scheda Prodotti restano: si azzerano i numeri, non le scelte."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLegacy" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}"
ToolTip="La vecchia cartella Dati delle versioni precedenti: i file JSON già importati nel database, le statistiche, i registri di testo e i dossier delle aste (possono essere gigabyte). L'applicazione non li legge più."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLearning" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Il modello appreso, il profilo per prodotto e ora, lo sfidante, il bandit, il modello di latenza e il contatore delle aste già studiate. Non si perde niente di irrecuperabile: al prossimo avvio l'applicazione ristudia da capo tutte le aste chiuse del database (qualche minuto in sottofondo)."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<Border Style="{StaticResource InfoBox}" Margin="0,14,0,0">
<StackPanel>
<TextBlock x:Name="SummaryText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="18"
Text="Calcolo…"/>
<TextBlock TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource Brush.TextMuted}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="17"
Text="Per cancellare tutto — database, copie di sicurezza, cartella vecchia — c'è «Elimina tutti i dati» nelle Impostazioni."/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button x:Name="WipeButton" Content="Azzera" Padding="26,7" Margin="0,0,10,0"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="Wipe_Click"/>
<Button Content="Annulla" Padding="20,7" IsCancel="True"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</StackPanel>
</Window>
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Windows;
using AutoBidder.Utilities;
namespace AutoBidder.Dialogs
{
/// <summary>
/// Sceglie quali statistiche azzerare e le azzera.
///
/// <para>Accanto a ogni voce c'è quanto pesa adesso: cancellare è irreversibile, e
/// l'unico modo di renderlo una decisione informata è dire cosa sparisce e quanto è.
/// Lo storico delle aste concluse è l'unica voce con copia di sicurezza automatica:
/// è il solo archivio che non si ricostruisce da nient'altro.</para>
/// </summary>
public partial class WipeStatsDialog : Window
{
private StatsWipe.Sizes _sizes;
public WipeStatsDialog()
{
InitializeComponent();
Loaded += (_, _) => { _sizes = StatsWipe.Measure(); Refresh(); };
}
/// <summary>Il rapporto dell'azzeramento, dopo la conferma. Null se annullato.</summary>
public StatsWipe.Report? Result { get; private set; }
private StatsWipe.Options ReadOptions() => new()
{
History = ChkHistory.IsChecked == true,
ProductStats = ChkProducts.IsChecked == true,
LegacyArchives = ChkLegacy.IsChecked == true,
Learning = ChkLearning.IsChecked == true
};
private void Option_Changed(object sender, RoutedEventArgs e) => Refresh();
private void Refresh()
{
if (!IsLoaded) return;
ChkHistory.Content = $"Osservazioni nel database: aste, puntate, interrogazioni, decisioni ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})";
ChkProducts.Content = $"Statistiche per prodotto ({Fmt(_sizes.ProductsBytes)})";
ChkLegacy.Content = $"Vecchia cartella Dati delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})";
var o = ReadOptions();
long totale = 0;
if (o.History) totale += _sizes.HistoryBytes;
if (o.ProductStats) totale += _sizes.ProductsBytes;
if (o.LegacyArchives) totale += _sizes.LegacyBytes;
if (o.Learning) totale += _sizes.LearningBytes;
SummaryText.Text = o.Nothing
? "Nessuna voce scelta."
: $"Si liberano circa {Fmt(totale)}." +
(o.History ? " Lo storico viene prima copiato nei backup." : "") +
(o.Learning ? " L'apprendimento ripartirà da zero e ristudierà le aste al prossimo avvio." : "");
WipeButton.IsEnabled = !o.Nothing;
}
private void Wipe_Click(object sender, RoutedEventArgs e)
{
var o = ReadOptions();
if (o.Nothing) return;
var answer = MessageBox.Show(this,
"Le voci scelte vengono cancellate. L'operazione non è reversibile" +
(o.History ? ", salvo la copia dello storico nei backup" : "") + ".\n\nProcedo?",
"Azzera le statistiche", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
Result = StatsWipe.Run(o);
DialogResult = true;
}
catch (Exception ex)
{
MessageBox.Show(this, $"Azzeramento non riuscito: {ex.Message}",
"Azzera le statistiche", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private static string Fmt(long bytes) =>
bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):F2} GB"
: bytes >= 1L << 20 ? $"{bytes / (double)(1L << 20):F1} MB"
: bytes >= 1L << 10 ? $"{bytes / (double)(1L << 10):F0} KB"
: $"{bytes} B";
}
}
+359
View File
@@ -0,0 +1,359 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Engine
{
/// <summary>
/// Servizi che il runner si aspetta dal monitor. Esiste per non legare il motore
/// all'implementazione del monitor: qui dentro c'è solo il timing.
/// </summary>
public interface IAuctionRunnerHost
{
/// <summary>Impostazioni correnti (già memorizzate in cache dal chiamante).</summary>
AppSettings Settings { get; }
/// <summary>Una singola interrogazione a data.php. null = nessuna risposta utilizzabile.</summary>
Task<AuctionState?> PollAsync(AuctionInfo auction, CancellationToken ct);
/// <summary>
/// Incorpora la risposta: aggiorna orologio server, scadenza, storico e interfaccia.
/// </summary>
void ApplySnapshot(AuctionInfo auction, AuctionState snapshot);
/// <summary>True quando l'asta è conclusa e il runner può spegnersi.</summary>
bool IsFinished(AuctionInfo auction);
/// <summary>
/// Verdetto delle strategie sulla puntata. Il runner decide <i>quando</i>,
/// questo metodo decide <i>se</i>. In shadow (asta in Osserva) la decisione si
/// registra ma la risposta è sempre «no»: si osserva, non si punta.
/// </summary>
bool EvaluateBid(AuctionInfo auction, AuctionState state, bool shadow = false);
/// <summary>Invia la puntata e registra l'esito.</summary>
Task FireBidAsync(AuctionInfo auction, AuctionState state, int leadMs, double actualLeadMs, CancellationToken ct);
}
/// <summary>
/// Motore di una singola asta. Due anelli indipendenti che girano in parallelo:
///
/// - <b>polling</b>: interroga data.php con cadenza che si stringe man mano che la
/// scadenza si avvicina, e aggiorna prezzo, ultimo puntatore e scadenza assoluta;
/// - <b>cecchino</b>: dorme fino a "scadenza meno anticipo" e spara la puntata lì.
///
/// Tenerli separati è la differenza sostanziale rispetto a un ticker unico condiviso:
/// una risposta lenta del server su un'asta non sposta di un millisecondo il momento
/// della puntata sulle altre, e il numero di aste seguite non degrada la precisione.
/// </summary>
public sealed class AuctionRunner
{
/// <summary>Quanto prima dell'istante di fuoco si eseguono i controlli di strategia.</summary>
private const double PreCheckMs = 60;
private readonly AuctionInfo _auction;
private readonly IAuctionRunnerHost _host;
private CancellationTokenSource? _cts;
private Task? _pollTask;
private Task? _sniperTask;
/// <summary>Scadenza (in secondi unix) del ciclo già gestito: garantisce una sola puntata per ciclo.</summary>
private long _handledCycle = -1;
public AuctionRunner(AuctionInfo auction, IAuctionRunnerHost host)
{
_auction = auction;
_host = host;
}
public AuctionInfo Auction => _auction;
public bool IsRunning => _pollTask is { IsCompleted: false };
public void Start()
{
if (IsRunning) return;
_cts = new CancellationTokenSource();
var token = _cts.Token;
_pollTask = Task.Run(() => PollLoopAsync(token), token);
_sniperTask = Task.Run(() => SniperLoopAsync(token), token);
}
public async Task StopAsync()
{
var cts = _cts;
if (cts is null) return;
_cts = null;
try { await cts.CancelAsync().ConfigureAwait(false); } catch { /* già annullato */ }
var tasks = new[] { _pollTask, _sniperTask }.Where(t => t is not null).Cast<Task>().ToArray();
if (tasks.Length > 0)
{
try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); }
catch { /* uscita forzata */ }
}
_pollTask = null;
_sniperTask = null;
cts.Dispose();
}
// ── Anello di polling ────────────────────────────────────────────────────
private async Task PollLoopAsync(CancellationToken ct)
{
var consecutiveErrors = 0;
while (!ct.IsCancellationRequested)
{
try
{
var settings = _host.Settings;
var snapshot = await _host.PollAsync(_auction, ct).ConfigureAwait(false);
if (snapshot is null)
{
consecutiveErrors++;
_auction.PollErrors++;
if (consecutiveErrors == 3)
{
_auction.AddLog(
"Nessuna risposta da data.php: verifica la connessione o il cookie",
AuctionLogLevel.Warning, AuctionLogCategory.Polling);
}
// Backoff progressivo, ma senza superare i 5 s: l'asta potrebbe ripartire.
var backoff = Math.Min(300 * consecutiveErrors, 5000);
if (!await Wait.DelayAsync(backoff, ct).ConfigureAwait(false)) return;
continue;
}
consecutiveErrors = 0;
_auction.PollCount++;
_host.ApplySnapshot(_auction, snapshot);
if (_host.IsFinished(_auction)) return;
if (!await Wait.DelayAsync(NextPollDelay(settings), ct).ConfigureAwait(false)) return;
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_auction.AddLog($"Errore nel polling: {ex.Message}",
AuctionLogLevel.Error, AuctionLogCategory.Polling);
if (!await Wait.DelayAsync(1000, ct).ConfigureAwait(false)) return;
}
}
}
/// <summary>
/// Cadenza del polling: fitta solo dove serve. Un'asta a otto minuti dalla scadenza
/// non ha bisogno di quattro chiamate al secondo, una a tre secondi sì.
/// </summary>
private int NextPollDelay(AppSettings settings)
{
// Un'asta non ancora cominciata ha una cadenza tutta sua: vedi NotStartedDelay.
// È l'unico caso rimasto in cui si aspetta, e non è una scelta di ritmo: fino
// all'apertura il server non ha niente di diverso da dire, e interrogarlo
// seicento volte al minuto per tre ore non anticipa l'inizio di un istante.
var notStarted = NotStartedDelay(settings);
if (notStarted > 0) return notStarted;
// ── Nessuna attesa: la cadenza la detta la rete ──────────────────────
//
// Prima qui c'erano quattro soglie configurabili (lontano / medio / vicino /
// critico). Sui registri di tre giorni non hanno mai funzionato come scritto:
// con 40 richieste al secondo di tetto globale spartite fra tutte le aste
// seguite, i 220 ms della finestra critica diventavano 596 ms misurati, e con
// il timer a un secondo restava un solo sguardo prima della scadenza.
//
// Il ritmo adesso è quello del giro di andata e ritorno: si riparte appena la
// risposta è arrivata. Con ping ~57 ms misurato sono circa diciassette
// interrogazioni al secondo per asta.
//
// Il freno non è più un numero scelto a tavolino ma la risposta del server:
// vedi la reazione a 429 e 503 in PollLoopAsync. Se Bidoo dice di rallentare,
// si rallenta; finché non lo dice, si va al massimo che regge la rete.
return 0;
}
/// <summary>
/// Cadenza per un'asta che deve ancora cominciare, oppure 0 se non è questo il caso.
///
/// <para>Bidoo programma le aste con ore di anticipo, e fino all'apertura non
/// succede assolutamente nulla: interrogarla ogni due secondi per tre ore sono
/// migliaia di richieste per non vedere mai un cambiamento. La cadenza si allarga
/// quindi in proporzione all'attesa.</para>
///
/// <para>Il punto delicato è il risveglio. L'attesa non supera mai il tempo che
/// manca all'apertura meno un margine, così l'ultima interrogazione cade
/// <i>prima</i> dell'inizio e il motore è già alla cadenza normale quando l'asta
/// parte: allargare la cadenza non deve mai far perdere l'apertura.</para>
/// </summary>
private int NotStartedDelay(AppSettings settings)
{
if (!settings.ScheduledAuctionBackoffEnabled) return 0;
var status = _auction.LastState?.Status;
if (status is not (AuctionStatus.Scheduled or AuctionStatus.Pending)) return 0;
var toStartMs = _auction.EstimatedRemainingMs();
if (toStartMs == double.MaxValue || toStartMs <= 0) return 0;
// Vicino all'apertura si torna subito alla cadenza normale.
var wakeMs = Math.Max(30, settings.ScheduledPollWakeSeconds) * 1000.0;
if (toStartMs <= wakeMs) return 0;
var desired = toStartMs > 3_600_000
? Math.Max(60, settings.ScheduledPollFarSeconds) * 1000.0 // oltre un'ora
: toStartMs > 600_000
? Math.Max(30, settings.ScheduledPollMidSeconds) * 1000.0 // 10-60 minuti
: 30_000.0; // ultimi 10 minuti
// Non dormire mai oltre il momento del risveglio: è ciò che garantisce di
// essere pronti all'inizio.
var untilWake = toStartMs - wakeMs;
return (int)Math.Clamp(Math.Min(desired, untilWake), 1000, 1_800_000);
}
/// <summary>
/// L'anticipo con cui parte la puntata su quest'asta.
///
/// <para>Un anticipo scritto a mano sull'asta vince sempre. Altrimenti decide il
/// modello di latenza — margine più coda del ping di questa sessione, dentro i
/// paletti delle impostazioni — e il valore scelto viene scritto sull'asta, così
/// la griglia e il dossier mostrano l'anticipo davvero in uso.</para>
/// </summary>
private int EffectiveLeadMs(AppSettings settings)
{
if (_auction.BidLeadIsManual && _auction.BidBeforeDeadlineMs > 0)
return _auction.BidBeforeDeadlineMs;
// Con l'apprendimento spento vale l'anticipo fisso: è l'interruttore in barra.
var lead = settings.AdaptiveLeadEnabled && settings.LearningEnabled
? Ml.LatencyModel.RecommendedLeadMs(settings)
: settings.DefaultBidBeforeDeadlineMs;
if (lead != _auction.BidBeforeDeadlineMs)
{
_auction.AddLog($"Anticipo adattivo: {lead} ms (era {_auction.BidBeforeDeadlineMs} ms)",
AuctionLogLevel.Debug, AuctionLogCategory.Ticker);
_auction.BidBeforeDeadlineMs = lead;
}
return lead;
}
// ── Anello del cecchino ──────────────────────────────────────────────────
private async Task SniperLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
// In Osserva il cecchino arriva fino all'istante di fuoco come in Attiva,
// ma invece di sparare registra cosa avrebbe fatto: è la modalità shadow,
// ed è così che una policy si giudica prima di darle soldi veri.
if (_auction.State == RunState.Stopped || _host.IsFinished(_auction))
{
if (!await Wait.DelayAsync(150, ct).ConfigureAwait(false)) return;
continue;
}
var shadow = _auction.State == RunState.Watch;
var state = _auction.LastState;
var deadline = _auction.DeadlineTicks;
if (state is null || deadline == 0)
{
if (!await Wait.DelayAsync(100, ct).ConfigureAwait(false)) return;
continue;
}
var cycle = state.ExpiryUnixSeconds;
if (cycle == _handledCycle)
{
// Su questo ciclo abbiamo già deciso: non succede più nulla per noi
// finché la scadenza non passa o non ne arriva una nuova. Invece di
// svegliarsi quaranta volte al secondo a vuoto — che con venti aste
// significa ottocento risvegli inutili — si dorme fino alla scadenza,
// con un tetto di un secondo per accorgersi dei cambi di stato.
var idle = Math.Clamp(PrecisionWait.MsUntil(deadline), 25, 1000);
if (!await Wait.DelayAsync((int)idle, ct).ConfigureAwait(false)) return;
continue;
}
var settings = _host.Settings;
var leadMs = EffectiveLeadMs(settings);
var fireTicks = deadline - PrecisionWait.MsToTicks(leadMs);
var msToDeadline = PrecisionWait.MsUntil(deadline);
if (msToDeadline <= 0)
{
// Il ciclo è scaduto senza che si arrivasse alla finestra utile.
if (_handledCycle != cycle)
{
_handledCycle = cycle;
_auction.AddLog(
$"Ciclo scaduto senza puntata (anticipo {leadMs} ms, ping medio {_auction.AverageLatencyMs:F0} ms)",
AuctionLogLevel.Debug, AuctionLogCategory.Ticker);
}
if (!await Wait.DelayAsync(25, ct).ConfigureAwait(false)) return;
continue;
}
var msToFire = PrecisionWait.MsUntil(fireTicks);
if (msToFire > PreCheckMs)
{
// Ricontrolla spesso: una puntata altrui sposta la scadenza in avanti.
var sleep = Math.Clamp(msToFire - PreCheckMs, 5, 200);
if (!await Wait.DelayAsync((int)sleep, ct).ConfigureAwait(false)) return;
continue;
}
if (!_host.EvaluateBid(_auction, state, shadow))
{
_handledCycle = cycle;
continue;
}
// Da qui il ciclo è nostro: nessun'altra iterazione lo riprenderà.
_handledCycle = cycle;
await PrecisionWait.UntilAsync(fireTicks, ct).ConfigureAwait(false);
// Ultimo controllo a costo zero: nel frattempo potremmo essere passati in testa.
if (_auction.LastState?.IsMyBid == true) continue;
var actualLeadMs = PrecisionWait.MsUntil(_auction.DeadlineTicks);
await _host.FireBidAsync(_auction, state, leadMs, actualLeadMs, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_auction.AddLog($"Errore nel cecchino: {ex.Message}",
AuctionLogLevel.Error, AuctionLogCategory.BidAttempt);
if (!await Wait.DelayAsync(250, ct).ConfigureAwait(false)) return;
}
}
}
}
}
+187
View File
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Rigioca le aste registrate nel database e riassume cosa avrebbe fatto il motore,
/// con un anticipo alla volta.
///
/// <para>Serve a due cose distinte. La prima è la <b>taratura</b>: si vede subito
/// quante puntate costerebbe ciascun anticipo, e dove sta il gradino oltre il quale
/// il costo esplode. La seconda è il <b>controllo di regressione</b>: se una strategia
/// comincia a rifiutare puntate che prima passavano, qui si vede come un numero,
/// invece che come aste perse una alla volta senza capire perché.</para>
/// </summary>
public static class BacktestReport
{
/// <summary>Riepilogo su un intero insieme di aste, per un dato anticipo.</summary>
public sealed class Aggregate
{
public int LeadMs { get; init; }
public int Auctions { get; init; }
public int Cycles { get; init; }
/// <summary>Cicli arrivati fino all'anticipo su tutte le aste.</summary>
public int Reached { get; init; }
/// <summary>Puntate che sarebbero partite davvero.</summary>
public int Bids { get; init; }
public Dictionary<string, int> Blocks { get; init; } = new(StringComparer.Ordinal);
public double MedianBidsPerAuction { get; init; }
public int P90BidsPerAuction { get; init; }
public int MaxBidsPerAuction { get; init; }
/// <summary>Aste in cui sarebbe bastata una sola puntata.</summary>
public int AuctionsWithOneBid { get; init; }
public int BlockedTotal => Blocks.Values.Sum();
public double BlockedShare => Reached > 0 ? BlockedTotal * 100.0 / Reached : 0;
}
/// <summary>
/// Rigioca ogni asta chiusa del database con ciascun anticipo indicato.
/// <paramref name="onProgress"/> riceve (fatte, totale).
/// </summary>
public static List<Aggregate> Run(
AuctionDatabase db,
IReadOnlyList<int> leads,
AppSettings settings,
string username = "",
int maxAuctions = 0,
Action<int, int>? onProgress = null)
{
db.Flush();
var ids = db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time")
.Select(r => r.Str("auction_id")).ToList();
if (maxAuctions > 0 && ids.Count > maxAuctions) ids = ids.Skip(ids.Count - maxAuctions).ToList();
var sessions = new List<DossierReader.Session>();
for (var i = 0; i < ids.Count; i++)
{
var s = DossierReader.FromDatabase(db, ids[i]);
if (s != null && s.IsUsable) sessions.Add(s);
}
return Run(sessions, leads, settings, username, onProgress);
}
/// <summary>Rigioca sessioni già in memoria (anche vecchi dossier letti da file).</summary>
public static List<Aggregate> Run(
IReadOnlyList<DossierReader.Session> sessions,
IReadOnlyList<int> leads,
AppSettings settings,
string username = "",
Action<int, int>? onProgress = null)
{
var perLead = leads.ToDictionary(l => l, _ => new List<BacktestRunner.Result>());
for (var i = 0; i < sessions.Count; i++)
{
var session = sessions[i];
if (!session.IsUsable) continue;
var cost = session.Header?.BidCostEuro ?? 0.20;
foreach (var lead in leads)
{
var options = new BacktestRunner.Options(lead, settings, username, cost);
perLead[lead].Add(BacktestRunner.Run(session, options));
}
onProgress?.Invoke(i + 1, sessions.Count);
}
return leads.Select(lead => Summarize(lead, perLead[lead])).ToList();
}
public static Aggregate Summarize(int leadMs, IReadOnlyList<BacktestRunner.Result> results)
{
var blocks = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var r in results)
foreach (var (key, n) in r.Blocks)
blocks[key] = blocks.TryGetValue(key, out var have) ? have + n : n;
var perAuction = results.Select(r => r.Bids).OrderBy(n => n).ToList();
return new Aggregate
{
LeadMs = leadMs,
Auctions = results.Count,
Cycles = results.Sum(r => r.Cycles),
Reached = results.Sum(r => r.Reached),
Bids = results.Sum(r => r.Bids),
Blocks = blocks,
MedianBidsPerAuction = perAuction.Count == 0 ? 0 : perAuction[perAuction.Count / 2],
P90BidsPerAuction = perAuction.Count == 0 ? 0 : perAuction[(int)(perAuction.Count * 0.90)],
MaxBidsPerAuction = perAuction.Count == 0 ? 0 : perAuction[^1],
AuctionsWithOneBid = perAuction.Count(n => n == 1)
};
}
/// <summary>Rende il riepilogo in testo, pronto per il registro o per il terminale.</summary>
public static string Format(IReadOnlyList<Aggregate> aggregates)
{
var sb = new StringBuilder();
var c = CultureInfo.GetCultureInfo("it-IT");
sb.AppendLine("RIGIOCATA SULLE ASTE CONCLUSE");
sb.AppendLine();
sb.AppendLine(" Il motore punta solo nei cicli che arrivano fino all'anticipo senza che");
sb.AppendLine(" nessun altro abbia puntato. Piu' l'anticipo e' largo, piu' cicli lo");
sb.AppendLine(" raggiungono, piu' puntate si spendono.");
sb.AppendLine();
sb.AppendLine(" Bidoo dichiara la scadenza al secondo intero: sotto il secondo i dati non");
sb.AppendLine(" distinguono nulla, quindi gli anticipi si confrontano a passi di 1000 ms.");
sb.AppendLine();
sb.AppendLine(" anticipo aste cicli raggiunti puntate bloccate mediana p90 max");
sb.AppendLine(" ---------------------------------------------------------------------------");
foreach (var a in aggregates)
{
sb.AppendLine(string.Format(c,
" {0,6} ms {1,6} {2,8} {3,11} {4,9} {5,9} {6,7} {7,4} {8,5}",
a.LeadMs, a.Auctions, a.Cycles, a.Reached, a.Bids,
$"{a.BlockedTotal} ({a.BlockedShare:F1}%)",
a.MedianBidsPerAuction, a.P90BidsPerAuction, a.MaxBidsPerAuction));
}
var withBlocks = aggregates.Where(a => a.BlockedTotal > 0).ToList();
if (withBlocks.Count > 0)
{
sb.AppendLine();
sb.AppendLine(" Blocchi per motivo:");
foreach (var a in withBlocks)
{
var detail = string.Join(", ", a.Blocks.OrderByDescending(kv => kv.Value)
.Select(kv => $"{kv.Key}={kv.Value}"));
sb.AppendLine($" anticipo {a.LeadMs} ms: {detail}");
}
}
else
{
sb.AppendLine();
sb.AppendLine(" Nessuna puntata bloccata dalle strategie.");
}
sb.AppendLine();
sb.AppendLine(" I blocchi 'pareggio', 'tetto-spesa' e 'tetto-puntate' sono i paletti");
sb.AppendLine(" dell'utente; 'duello', 'valore-atteso', 'sfogo' e 'sondaggio' sono cio'");
sb.AppendLine(" che il sistema ha imparato o misurato sul momento.");
sb.AppendLine();
sb.AppendLine(" NOTA: la rigiocata misura i costi, non l'esito. Una nostra puntata rimette");
sb.AppendLine(" in gioco l'asta, e come avrebbero reagito gli avversari non e' registrato");
sb.AppendLine(" da nessuna parte: dichiarare delle vittorie sarebbe inventarle.");
return sb.ToString();
}
}
}
+349
View File
@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Rigioca un'asta già conclusa e conta cosa avrebbe fatto il motore.
///
/// <para><b>Cosa misura davvero.</b> Il motore punta soltanto nei cicli che arrivano
/// fino all'anticipo impostato senza che nessun altro abbia puntato: se un avversario
/// punta prima, la scadenza si sposta e il cecchino non spara. Il dossier registra,
/// per ogni ciclo, quanto è sceso il timer prima che qualcuno intervenisse — che è
/// esattamente il dato che serve. Da lì si ricava <b>quante puntate sarebbero state
/// spese</b> e <b>quante volte una strategia le avrebbe bloccate</b>.</para>
///
/// <para><b>Cosa non può misurare, e perché va detto.</b> Non dice se avresti vinto.
/// Una nostra puntata rimette in gioco l'asta, e come avrebbero reagito gli avversari
/// non sta in nessun registro: nessuna rigiocata può inventarselo. L'ultimo ciclo di
/// ogni asta arriva per forza a zero — è il motivo per cui l'asta è finita — quindi
/// contarlo come vittoria sarebbe una conclusione costruita a tavolino. Qui si contano
/// i costi e i blocchi, che sono osservabili; l'esito no.</para>
/// </summary>
public static class BacktestRunner
{
/// <summary>Parametri della rigiocata.</summary>
public sealed record Options(
int LeadMs,
AppSettings Settings,
string Username = "",
double BidCostEuro = 0.20)
{
/// <summary>
/// Le impostazioni come le vede la rigiocata.
///
/// <para>Spegne la sospensione a fascia oraria, che guarda l'orologio di parete:
/// una rigiocata ripercorre aste già concluse, e l'ora in cui viene lanciata non
/// c'entra nulla con l'ora in cui quelle aste correvano. Lasciarla accesa
/// renderebbe il risultato dipendente da quando si preme il pulsante — di notte
/// il motore rifiuterebbe ogni puntata e la rigiocata direbbe zero, che è
/// esattamente il tipo di risultato che sembra vero e non lo è.</para>
///
/// <para>Vale anche per la suite di test, che gira a qualunque ora.</para>
/// </summary>
public AppSettings EffectiveSettings { get; } = Clona(Settings);
private static AppSettings Clona(AppSettings origine)
{
var copia = origine ?? new AppSettings();
// Copia superficiale via serializzazione: le impostazioni sono un sacco di
// proprietà semplici, e mutare quella dell'utente sarebbe un effetto
// collaterale che nessuno si aspetta da una rigiocata.
var json = System.Text.Json.JsonSerializer.Serialize(copia);
var clone = System.Text.Json.JsonSerializer.Deserialize<AppSettings>(json)
?? new AppSettings();
clone.QuietHoursEnabled = false;
return clone;
}
}
/// <summary>Esito su una singola asta.</summary>
public sealed class Result
{
public string AuctionId { get; init; } = "";
public string Name { get; init; } = "";
public string ProductKey { get; init; } = "";
/// <summary>Cicli di timer osservati nel dossier.</summary>
public int Cycles { get; init; }
/// <summary>Cicli arrivati fino all'anticipo: qui il motore avrebbe sparato.</summary>
public int Reached { get; init; }
/// <summary>Puntate davvero inviate, cioè i cicli raggiunti che nessuna strategia ha fermato.</summary>
public int Bids { get; init; }
/// <summary>Blocchi per motivo, con il testo che avrebbe scritto la strategia.</summary>
public Dictionary<string, int> Blocks { get; init; } = new(StringComparer.Ordinal);
public double FinalPrice { get; init; }
public double? BuyNowPrice { get; init; }
/// <summary>Costo se l'asta fosse stata vinta all'ultima puntata: prezzo + puntate spese.</summary>
public double CostIfWon { get; init; }
public int BlockedTotal => Blocks.Values.Sum();
}
/// <summary>Motivi di blocco riconosciuti, per raggrupparli senza dipendere dal testo esatto.</summary>
public static string Categorize(string? reason)
{
if (string.IsNullOrEmpty(reason)) return "altro";
if (reason.StartsWith("autopuntata avversaria", StringComparison.OrdinalIgnoreCase)) return "duello";
if (reason.StartsWith("puntata saltata: fascia", StringComparison.OrdinalIgnoreCase)) return "fascia-oraria";
if (reason.StartsWith("valore atteso", StringComparison.OrdinalIgnoreCase)) return "valore-atteso";
if (reason.StartsWith("lascio sfogare", StringComparison.OrdinalIgnoreCase)) return "sfogo";
if (reason.StartsWith("puntata di prova", StringComparison.OrdinalIgnoreCase)) return "sondaggio";
if (reason.StartsWith("Limite puntate", StringComparison.OrdinalIgnoreCase)) return "limite-puntate";
if (reason.StartsWith("Budget", StringComparison.OrdinalIgnoreCase)) return "budget";
return "altro";
}
public static Result Run(DossierReader.Session session, Options options)
{
var header = session.Header;
var cycles = BuildCycles(session.Polls);
var strategy = new BidStrategyService();
var auction = NewAuction(header, options);
var blocks = new Dictionary<string, int>(StringComparer.Ordinal);
var reached = 0;
var bids = 0;
// Le puntate in ordine cronologico: a ogni ciclo si mostra al motore solo
// quelle che a quel momento erano già visibili. Mostrargliele tutte sarebbe
// dargli il futuro, e le strategie che guardano lo storico ne uscirebbero
// giudicate su informazioni che non avranno mai.
var bidsInOrder = session.Bids
.Where(b => b.UnixSeconds > 0)
.OrderBy(b => b.T)
.ToList();
var nextBid = 0;
foreach (var cycle in cycles)
{
while (nextBid < bidsInOrder.Count && bidsInOrder[nextBid].T <= cycle.LastPollT)
{
Publish(auction, bidsInOrder[nextBid], options.Username);
nextBid++;
}
// Il ciclo è arrivato fino a noi solo se il timer è sceso sotto l'anticipo.
//
// Bidoo dichiara la scadenza al secondo intero, quindi un ciclo che risulta
// "sceso a 2" aveva in realtà fra 2 e 3 secondi davanti: il valore letto è un
// pavimento, non una misura. Per non contare puntate che forse non sarebbero
// partite si richiede il secondo <b>pieno</b> — con anticipo 2000 ms si
// considerano solo i cicli scesi sotto i 2 s. È il conteggio prudente, e la
// prudenza qui va verso il basso: meglio sottostimare il costo che promettere
// un risparmio che i dati non possono garantire.
if ((cycle.MinTimerSeconds + 1) * 1000.0 > options.LeadMs) continue;
var state = ToState(cycle, options.Username);
// Fuori corsa: il motore non punta su un'asta non aperta.
if (state.Status != AuctionStatus.Running && state.Status != AuctionStatus.Pending) continue;
if (state.IsMyBid) continue;
reached++;
// Budget e pareggio: e' il controllo che decide se la puntata ha ancora
// senso economico, e va rigiocato come tutti gli altri. Senza, la prova
// misurerebbe tutto tranne la cosa che protegge il portafoglio.
var budget = BidBudget.Evaluate(new BidBudget.Situation(
CurrentPrice: cycle.Price,
BidsAlreadyUsed: bids,
BidCostEuro: options.BidCostEuro,
ProductValue: header?.BuyNowPrice,
ShippingCost: null,
MaxBids: auction.MaxClicks,
MaxTotalSpendEuro: auction.MaxTotalSpendEuro,
MinSavingsPercentage: options.EffectiveSettings.MinSavingsPercentage,
StopAtBreakEven: auction.StopAtBreakEven));
if (!budget.CanBid)
{
var key = budget.Reason?.Contains("pareggio") == true ? "pareggio"
: budget.Reason?.Contains("spesa") == true ? "tetto-spesa"
: "tetto-puntate";
blocks[key] = blocks.TryGetValue(key, out var b) ? b + 1 : 1;
continue;
}
var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username, allAuctions: null, shadow: true);
if (!decision.ShouldBid)
{
var key = Categorize(decision.Reason);
blocks[key] = blocks.TryGetValue(key, out var n) ? n + 1 : 1;
continue;
}
bids++;
// Una puntata riuscita: è ciò che il motore registrerebbe, e alimenta i
// contatori su cui le strategie stesse decidono ai giri successivi.
strategy.RecordBidAttempt(auction, success: true);
auction.BidsUsedOnThisAuction = (auction.BidsUsedOnThisAuction ?? 0) + 1;
}
var finalPrice = session.Summary?.FinalPrice ?? cycles.LastOrDefault()?.Price ?? 0;
return new Result
{
AuctionId = header?.AuctionId ?? "",
Name = header?.Name ?? "",
ProductKey = header?.ProductKey ?? "",
Cycles = cycles.Count,
Reached = reached,
Bids = bids,
Blocks = blocks,
FinalPrice = finalPrice,
BuyNowPrice = header?.BuyNowPrice,
CostIfWon = finalPrice + bids * options.BidCostEuro
};
}
// ── Ricostruzione dei cicli ──────────────────────────────────────
/// <summary>
/// Un ciclo di timer: dalla puntata che l'ha aperto alla successiva (o alla fine).
/// Lo identifica la <b>scadenza</b> dichiarata dal server, che cambia a ogni
/// puntata: è la stessa chiave che usa il cecchino per non puntare due volte
/// sullo stesso ciclo.
/// </summary>
public sealed record Cycle(
long ExpiryUnix,
double MinTimerSeconds,
double Price,
string LastBidder,
bool Mine,
string Status,
long ServerUnix,
double PingMs,
double LastPollT);
public static List<Cycle> BuildCycles(IReadOnlyList<DossierReader.Poll> polls)
{
var cycles = new List<Cycle>();
long currentExpiry = 0;
var minTimer = double.MaxValue;
DossierReader.Poll? deepest = null;
foreach (var poll in polls)
{
if (poll.ExpiryUnix <= 0 || poll.ServerUnix <= 0) continue;
if (poll.ExpiryUnix != currentExpiry)
{
if (deepest != null) cycles.Add(Close(currentExpiry, minTimer, deepest));
currentExpiry = poll.ExpiryUnix;
minTimer = double.MaxValue;
deepest = null;
}
// Il timer calcolato dai due istanti del server, non quello riportato:
// sono la stessa cosa, ma questo non dipende da come è stato arrotondato.
var timer = poll.ExpiryUnix - poll.ServerUnix;
if (timer <= minTimer)
{
minTimer = timer;
deepest = poll;
}
}
if (deepest != null) cycles.Add(Close(currentExpiry, minTimer, deepest));
return cycles;
}
private static Cycle Close(long expiry, double minTimer, DossierReader.Poll deepest) => new(
ExpiryUnix: expiry,
MinTimerSeconds: Math.Max(0, minTimer),
Price: deepest.Price,
LastBidder: deepest.LastBidder,
Mine: deepest.Mine,
Status: deepest.Status,
ServerUnix: deepest.ServerUnix,
PingMs: deepest.PingMs,
LastPollT: deepest.T);
// ── Adattatori verso i modelli del motore ────────────────────────
private static AuctionState ToState(Cycle cycle, string username) => new()
{
AuctionId = "",
Timer = cycle.MinTimerSeconds,
Price = cycle.Price,
LastBidder = cycle.LastBidder,
IsMyBid = cycle.Mine ||
(!string.IsNullOrEmpty(username) &&
cycle.LastBidder.Equals(username, StringComparison.OrdinalIgnoreCase)),
Status = ParseStatus(cycle.Status),
ServerUnixSeconds = cycle.ServerUnix,
ExpiryUnixSeconds = cycle.ExpiryUnix,
PollingLatencyMs = (int)cycle.PingMs,
SnapshotTime = DateTimeOffset.FromUnixTimeSeconds(cycle.ServerUnix).UtcDateTime
};
private static AuctionStatus ParseStatus(string? status) =>
Enum.TryParse<AuctionStatus>(status, ignoreCase: true, out var parsed)
? parsed
: AuctionStatus.Unknown;
private static AuctionInfo NewAuction(DossierReader.Header? header, Options options) => new()
{
AuctionId = header?.AuctionId ?? "",
Name = header?.Name ?? "",
BuyNowPrice = header?.BuyNowPrice,
BidBeforeDeadlineMs = options.LeadMs,
BidLeadIsManual = true, // la rigiocata prova un anticipo preciso, non quello adattivo
BidsUsedOnThisAuction = 0
};
/// <summary>
/// Rende visibile al motore una puntata, come farebbe l'incorporazione di una
/// risposta di data.php: in testa alla lista, con il tetto delle ultime cinquanta.
/// </summary>
private static void Publish(AuctionInfo auction, DossierReader.Bid bid, string username)
{
var entry = new BidHistoryEntry
{
Price = (decimal)bid.Price,
Username = bid.User,
Timestamp = bid.UnixSeconds,
BidType = "Auto",
IsMyBid = bid.Mine ||
(!string.IsNullOrEmpty(username) &&
bid.User.Equals(username, StringComparison.OrdinalIgnoreCase))
};
lock (auction.BidsLock)
{
auction.RecentBids.Insert(0, entry);
if (auction.RecentBids.Count > 50) auction.RecentBids.RemoveRange(50, auction.RecentBids.Count - 50);
if (!auction.BidderStats.TryGetValue(bid.User, out var stats))
{
stats = new BidderInfo { Username = bid.User };
auction.BidderStats[bid.User] = stats;
}
stats.BidCount++;
}
}
}
}
+265
View File
@@ -0,0 +1,265 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Porta in memoria un'asta registrata come sequenza di eventi tipizzati, per
/// rigiocarla contro il motore.
///
/// <para>La fonte normale è il database (<see cref="FromDatabase"/>). Resta il lettore
/// dei vecchi dossier JSON Lines (<see cref="Read"/>) per i test scritti a mano e per
/// un'eventuale importazione dell'archivio precedente. Una riga che non si capisce
/// viene saltata, non fa fallire la lettura.</para>
/// </summary>
public static class DossierReader
{
/// <summary>Intestazione: identità dell'asta e configurazione con cui era seguita.</summary>
public sealed record Header(
string AuctionId,
string Name,
string ProductKey,
double? BuyNowPrice,
double BidCostEuro,
int ConfiguredLeadMs,
string State);
/// <summary>Una singola interrogazione a data.php.</summary>
public sealed record Poll(
double T,
double Price,
double Timer,
string Status,
string LastBidder,
bool Mine,
long ExpiryUnix,
long ServerUnix,
double PingMs);
/// <summary>Una puntata altrui (o nostra) letta dallo storico.</summary>
public sealed record Bid(
double T,
string User,
long UnixSeconds,
double Price,
bool Mine);
/// <summary>Riepilogo scritto alla chiusura dell'asta.</summary>
public sealed record Summary(
string Outcome,
string Winner,
double FinalPrice,
bool WonByMe,
int Resets,
int DistinctBidders,
int MyBids);
/// <summary>Un'asta completa.</summary>
public sealed class Session
{
public Header? Header { get; init; }
public Summary? Summary { get; init; }
public List<Poll> Polls { get; init; } = new();
public List<Bid> Bids { get; init; } = new();
/// <summary>Righe che non si è riusciti a interpretare (solo dai file).</summary>
public int SkippedLines { get; init; }
/// <summary>Senza intestazione e interrogazioni non c'è nulla da rigiocare.</summary>
public bool IsUsable => Header != null && Polls.Count > 0;
}
// ── Dal database ─────────────────────────────────────────────────
public static Session? FromDatabase(AuctionDatabase db, string auctionId)
{
var a = db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auctionId);
if (a == null) return null;
var state = "";
try
{
var cfg = a.StrOrNull("config_json");
if (!string.IsNullOrEmpty(cfg))
{
using var doc = JsonDocument.Parse(cfg);
if (doc.RootElement.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String)
state = s.GetString() ?? "";
}
}
catch { }
var header = new Header(
AuctionId: a.Str("auction_id"),
Name: a.Str("name"),
ProductKey: a.Str("product_key"),
BuyNowPrice: a.DblOrNull("buy_now_price"),
BidCostEuro: a.DblOrNull("bid_fee") is { } fee && fee > 0 ? fee : 0.20,
ConfiguredLeadMs: a.Int("configured_lead_ms"),
State: state);
var summary = a.StrOrNull("outcome") == null ? null : new Summary(
Outcome: a.Str("outcome"),
Winner: a.Str("winner_user"),
FinalPrice: a.Dbl("final_price"),
WonByMe: a.Bool("won_by_me"),
Resets: a.Int("resets"),
DistinctBidders: a.Int("distinct_bidders"),
MyBids: a.Int("my_bids"));
var polls = db.Query(
"SELECT t, price, timer, status, last_bidder, is_mine, expiry_unix, server_unix, ping_ms FROM polls " +
"WHERE auction_id = ?1 ORDER BY t", auctionId)
.Select(r => new Poll(
T: r.Dbl("t"), Price: r.Dbl("price"), Timer: r.Dbl("timer"), Status: r.Str("status"),
LastBidder: r.Str("last_bidder"), Mine: r.Bool("is_mine"),
ExpiryUnix: r.Long("expiry_unix"), ServerUnix: r.Long("server_unix"), PingMs: r.Dbl("ping_ms")))
.ToList();
var bids = db.Query(
"SELECT seen_t, username, server_ts, price_after, is_mine FROM bids WHERE auction_id = ?1 " +
"ORDER BY COALESCE(server_ts, 0), price_after", auctionId)
.Select(r => new Bid(
T: r.Dbl("seen_t"), User: r.Str("username"), UnixSeconds: r.LongOrNull("server_ts") ?? 0,
Price: r.Dbl("price_after"), Mine: r.Bool("is_mine")))
.ToList();
return new Session { Header = header, Summary = summary, Polls = polls, Bids = bids };
}
// ── Dai vecchi dossier JSON Lines ────────────────────────────────
public static Session ReadFile(string path) => Read(File.ReadLines(path));
public static Session Read(IEnumerable<string> lines)
{
Header? header = null;
Summary? summary = null;
var polls = new List<Poll>();
var bids = new List<Bid>();
var skipped = 0;
foreach (var raw in lines)
{
if (string.IsNullOrWhiteSpace(raw)) continue;
var line = raw.TrimStart('', ' ', '\t');
if (line.Length == 0 || line[0] != '{') { skipped++; continue; }
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
switch (Str(root, "type"))
{
case "header": header = ReadHeader(root); break;
case "summary": summary = ReadSummary(root); break;
case "poll": polls.Add(ReadPoll(root)); break;
case "bid": bids.Add(ReadBid(root)); break;
}
}
catch (JsonException)
{
skipped++;
}
}
return new Session
{
Header = header,
Summary = summary,
Polls = polls,
Bids = bids,
SkippedLines = skipped
};
}
private static Header ReadHeader(JsonElement root)
{
var product = Obj(root, "product");
var config = Obj(root, "config");
return new Header(
AuctionId: Str(root, "auctionId") ?? "",
Name: Str(root, "name") ?? "",
ProductKey: Str(root, "productKey") ?? "",
BuyNowPrice: product.HasValue ? NullableNum(product.Value, "buyNowPrice") : null,
BidCostEuro: product.HasValue ? Num(product.Value, "bidCostEuro", 0.20) : 0.20,
ConfiguredLeadMs: config.HasValue ? (int)Num(config.Value, "bidBeforeDeadlineMs", 0) : 0,
State: config.HasValue ? Str(config.Value, "state") ?? "" : "");
}
private static Summary ReadSummary(JsonElement root)
{
var part = Obj(root, "participation");
return new Summary(
Outcome: Str(root, "outcome") ?? "",
Winner: Str(root, "winner") ?? "",
FinalPrice: Num(root, "finalPrice", 0),
WonByMe: Bool(root, "wonByMe"),
Resets: part.HasValue ? (int)Num(part.Value, "resets", 0) : 0,
DistinctBidders: part.HasValue ? (int)Num(part.Value, "distinctBidders", 0) : 0,
MyBids: part.HasValue ? (int)Num(part.Value, "myBids", 0) : 0);
}
private static Poll ReadPoll(JsonElement root) => new(
T: Num(root, "t", 0),
Price: Num(root, "price", 0),
Timer: Num(root, "timer", 0),
Status: Str(root, "status") ?? "",
LastBidder: Str(root, "lastBidder") ?? "",
Mine: Bool(root, "mine"),
ExpiryUnix: (long)Num(root, "expiryUnix", 0),
ServerUnix: (long)Num(root, "serverUnix", 0),
PingMs: Num(root, "pingMs", 0));
private static Bid ReadBid(JsonElement root) => new(
T: Num(root, "t", 0),
User: Str(root, "user") ?? "",
UnixSeconds: ParseUnix(Str(root, "bidAt")),
Price: Num(root, "price", 0),
Mine: Bool(root, "mine"));
private static long ParseUnix(string? iso)
{
if (string.IsNullOrWhiteSpace(iso)) return 0;
return DateTimeOffset.TryParse(iso, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var parsed)
? parsed.ToUnixTimeSeconds()
: 0;
}
private static string? Str(JsonElement owner, string name) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static bool Bool(JsonElement owner, string name) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
private static double Num(JsonElement owner, string name, double fallback) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number &&
v.TryGetDouble(out var d)
? d
: fallback;
private static double? NullableNum(JsonElement owner, string name) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number &&
v.TryGetDouble(out var d)
? d
: null;
private static JsonElement? Obj(JsonElement owner, string name) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Object
? v
: null;
}
}
+116
View File
@@ -0,0 +1,116 @@
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace AutoBidder.Engine
{
/// <summary>
/// Attesa fino a un istante espresso in tick di <see cref="Stopwatch"/>.
///
/// Task.Delay si appoggia al timer di sistema, che di default scatta ogni 15,6 ms: per un
/// anticipo di 200 ms sulla scadenza è un errore inaccettabile. Qui si dorme finché manca
/// parecchio, lasciando un margine più ampio della granularità del timer, e si copre
/// l'ultimo tratto con attesa attiva. Il costo è qualche decina di millisecondi di CPU per
/// puntata; il guadagno è un errore sotto il millisecondo.
/// </summary>
public static class PrecisionWait
{
/// <summary>
/// Margine da coprire in attesa attiva. Più ampio della granularità del timer di
/// sistema, così anche un Task.Delay che sfora si risveglia comunque prima del bersaglio.
/// </summary>
private const double SpinThresholdMs = 22;
/// <summary>Ultimo tratto in cui non si cede più il core a nessuno.</summary>
private const double TightSpinMs = 2.0;
public static async Task UntilAsync(long targetTicks, CancellationToken ct)
{
var frequency = Stopwatch.Frequency;
while (!ct.IsCancellationRequested)
{
var remainingMs = (targetTicks - Stopwatch.GetTimestamp()) * 1000.0 / frequency;
if (remainingMs <= 0) return;
if (remainingMs > SpinThresholdMs)
{
var sleepMs = remainingMs - SpinThresholdMs;
await Task.Delay(TimeSpan.FromMilliseconds(Math.Min(sleepMs, 1000)), ct).ConfigureAwait(false);
continue;
}
SpinUntil(targetTicks, ct);
return;
}
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private static void SpinUntil(long targetTicks, CancellationToken ct)
{
var frequency = Stopwatch.Frequency;
while (true)
{
var remainingMs = (targetTicks - Stopwatch.GetTimestamp()) * 1000.0 / frequency;
if (remainingMs <= 0 || ct.IsCancellationRequested) return;
// Finché c'è margine si cede volentieri il core; nell'ultimo tratto no,
// altrimenti bastano due millisecondi di preemption per arrivare tardi.
if (remainingMs > TightSpinMs) Thread.Yield();
else Thread.SpinWait(40);
}
}
/// <summary>Millisecondi mancanti a un istante espresso in tick.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static double MsUntil(long targetTicks) =>
(targetTicks - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency;
/// <summary>Converte millisecondi in tick di <see cref="Stopwatch"/>.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long MsToTicks(double ms) => (long)(ms / 1000.0 * Stopwatch.Frequency);
}
/// <summary>
/// Porta la granularità del timer di sistema a 1 ms per tutta la durata del processo.
///
/// Senza questa richiesta ogni Task.Delay può sforare di ~15 ms: si ripercuote sulla
/// cadenza del polling e allunga l'attesa attiva necessaria prima di ogni puntata.
/// È la stessa cosa che fanno browser e riproduttori multimediali; l'unico effetto
/// collaterale è un consumo leggermente maggiore, accettabile per un'applicazione che
/// deve rispettare scadenze al millisecondo.
/// </summary>
public sealed class SystemTimerResolution : IDisposable
{
[DllImport("winmm.dll", EntryPoint = "timeBeginPeriod")]
private static extern uint TimeBeginPeriod(uint milliseconds);
[DllImport("winmm.dll", EntryPoint = "timeEndPeriod")]
private static extern uint TimeEndPeriod(uint milliseconds);
private const uint TargetMs = 1;
private const uint Ok = 0;
private bool _active;
public bool IsActive => _active;
public SystemTimerResolution()
{
try { _active = TimeBeginPeriod(TargetMs) == Ok; }
catch { _active = false; }
}
public void Dispose()
{
if (!_active) return;
try { TimeEndPeriod(TargetMs); } catch { /* rilascio best-effort */ }
_active = false;
}
}
}
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Diagnostics;
namespace AutoBidder.Engine
{
/// <summary>
/// Allinea l'orologio monotono locale a quello del server Bidoo.
///
/// data.php restituisce timestamp in secondi interi: preso da solo, un singolo campione
/// vale ±1 s. Ma ogni risposta dice anche "il mio secondo corrente è S", e la risposta è
/// stata generata prima di arrivare qui. Detto A = istante locale del confine del secondo
/// server 0, ogni campione fornisce un limite superiore per A; il minimo su molti campioni
/// converge al valore vero (filtro alla NTP).
///
/// L'errore residuo è per costruzione verso il basso, cioè verso una scadenza stimata
/// leggermente in anticipo: si punta un filo prima, mai dopo.
/// </summary>
public sealed class ServerClock
{
private const int WindowSize = 256;
private readonly long[] _samples = new long[WindowSize];
/// <summary>
/// Round-trip di ciascun campione. Vive qui perché qui arriva già misurato, e
/// perché è la latenza che conta davvero: quella delle richieste di polling, le
/// stesse su cui il motore decide quando puntare.
/// </summary>
private readonly int[] _latencies = new int[WindowSize];
private readonly object _sync = new();
private int _index;
private int _count;
private long _latencySum;
/// <summary>Numero di campioni utilizzati per la stima corrente.</summary>
public int SampleCount { get { lock (_sync) return _count; } }
/// <summary>True quando la stima è abbastanza solida da essere usata per il timing.</summary>
public bool IsSynced { get { lock (_sync) return _count >= 3; } }
/// <summary>Scarto residuo stimato in millisecondi (dispersione dei campioni).</summary>
public double SpreadMs { get; private set; }
/// <summary>
/// Round-trip medio verso Bidoo sugli ultimi campioni, in millisecondi.
/// Zero finché non è arrivata nessuna risposta.
/// </summary>
public double AverageLatencyMs
{
get { lock (_sync) return _count == 0 ? 0 : (double)_latencySum / _count; }
}
/// <summary>Tick locale corrispondente al secondo unix 0 del server.</summary>
public long Origin { get; private set; }
/// <summary>Registra una risposta del server.</summary>
/// <param name="serverUnixSeconds">Secondo unix dichiarato dal server.</param>
/// <param name="receivedTicks">Tick di <see cref="Stopwatch"/> alla ricezione.</param>
/// <param name="latencyMs">Round-trip misurato.</param>
public void AddSample(long serverUnixSeconds, long receivedTicks, int latencyMs)
{
if (serverUnixSeconds <= 0 || receivedTicks <= 0) return;
var f = Stopwatch.Frequency;
// Momento in cui il server ha prodotto la risposta: metà round-trip fa.
var generatedTicks = receivedTicks - (long)(latencyMs / 2.0 / 1000.0 * f);
// Limite superiore per l'origine A.
var candidate = generatedTicks - serverUnixSeconds * f;
lock (_sync)
{
// La finestra è circolare: prima di sovrascrivere si toglie dalla somma
// il campione che esce, altrimenti la media resterebbe ferma sul passato.
if (_count == WindowSize) _latencySum -= _latencies[_index];
_samples[_index] = candidate;
_latencies[_index] = latencyMs < 0 ? 0 : latencyMs;
_latencySum += _latencies[_index];
_index = (_index + 1) % WindowSize;
if (_count < WindowSize) _count++;
long min = long.MaxValue, max = long.MinValue;
for (var i = 0; i < _count; i++)
{
var v = _samples[i];
if (v < min) min = v;
if (v > max) max = v;
}
Origin = min;
SpreadMs = (max - min) * 1000.0 / f;
}
}
/// <summary>Converte un timestamp unix del server in tick locali di <see cref="Stopwatch"/>.</summary>
public long ToLocalTicks(long serverUnixSeconds)
{
lock (_sync)
{
return Origin + serverUnixSeconds * Stopwatch.Frequency;
}
}
public void Reset()
{
lock (_sync)
{
Array.Clear(_samples);
Array.Clear(_latencies);
_index = 0;
_count = 0;
_latencySum = 0;
Origin = 0;
SpreadMs = 0;
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="17px" height="18px" viewBox="0 0 17 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: sketchtool 57.1 (101010) - https://sketch.com -->
<title>E3DC3394-397D-4994-B12B-47234FB13863</title>
<desc>Created with sketchtool.</desc>
<g id="Product-Pages" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Home---Portrait-Version-A-Copy-6" transform="translate(-77.000000, -637.000000)">
<g id="Group-20" transform="translate(63.000000, 553.000000)">
<g id="Group-17-Copy" transform="translate(14.000000, 84.000000)">
<g id="001-settings" transform="translate(0.000000, 0.500000)">
<path d="M15.6392002,7.48800011 C14.1532002,7.20200011 13.4720002,5.46640008 14.3672002,4.24600006 L14.9952002,3.38800005 L13.6376002,2.03040003 L12.7936002,2.60200004 C11.5408002,3.45200005 9.83120015,2.70520004 9.60160014,1.21000002 L9.44080014,0.160000002 L7.52040011,0.160000002 L7.27200011,1.45360002 C6.9920001,2.90520004 5.31720008,3.59920005 4.09200006,2.76920004 L3.00320004,2.03040003 L1.64520002,3.38800005 L2.27360003,4.24600006 C3.16880005,5.46640008 2.48600004,7.20200011 1.00160001,7.48800011 L0,7.68040011 L0,9.60080014 L1.05000002,9.76160015 C2.54520004,9.99120015 3.29200005,11.7008002 2.44200004,12.9536002 L1.87040003,13.7976002 L3.22800005,15.1552002 L4.08600006,14.5272002 C5.30640008,13.6320002 7.0420001,14.3132002 7.32800011,15.7992002 L7.52040011,16.8008003 L9.44080014,16.8008003 L9.55320014,16.0616002 C9.78920015,14.5336002 11.5608002,13.7992002 12.8080002,14.7132002 L13.4108002,15.1552002 L14.7688002,13.7976002 L14.1968002,12.9536002 C13.3484002,11.7008002 14.0936002,9.99120015 15.5892002,9.76160015 L16.6408002,9.60080014 L16.6408002,7.68040011 L15.6392002,7.48800011 Z M8.47960013,10.0804002 C7.68440011,10.0804002 7.0408001,9.43520014 7.0408001,8.63960013 C7.0408001,7.84440012 7.68440011,7.20080011 8.47960013,7.20080011 C9.27520014,7.20080011 9.92040015,7.84440012 9.92040015,8.63960013 C9.92040015,9.43520014 9.27520014,10.0804002 8.47960013,10.0804002 Z" id="Fill-1" fill="#C7CAC7"/>
<path d="M8.47960013,5.60080008 C6.8016001,5.60080008 5.44080008,6.9616001 5.44080008,8.63960013 C5.44080008,10.3192002 6.8016001,11.6804002 8.47960013,11.6804002 C10.1592002,11.6804002 11.5204002,10.3192002 11.5204002,8.63960013 C11.5204002,6.9616001 10.1592002,5.60080008 8.47960013,5.60080008 Z M8.47960013,10.0804002 C7.68440011,10.0804002 7.0408001,9.43520014 7.0408001,8.63960013 C7.0408001,7.84440012 7.68440011,7.20080011 8.47960013,7.20080011 C9.27520014,7.20080011 9.92040015,7.84440012 9.92040015,8.63960013 C9.92040015,9.43520014 9.27520014,10.0804002 8.47960013,10.0804002 Z" id="Fill-2" fill="#556080"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.96 32.35"><defs><style>.cls-1{fill:#38454f;}.cls-2{fill:#1caee4;}.cls-3{fill:#1081e0;}.cls-4{fill:#cbd4d8;}.cls-5{fill:#546a79;}</style></defs><title>Asset 10002-app</title><g id="Layer_2" data-name="Layer 2"><g id="Livello_1" data-name="Livello 1"><path class="cls-1" d="M15.76,32.35H3.2A3.21,3.21,0,0,1,0,29.14V3.2A3.2,3.2,0,0,1,3.2,0H15.76A3.2,3.2,0,0,1,19,3.2V29.14A3.21,3.21,0,0,1,15.76,32.35Z"/><rect class="cls-2" x="1.67" y="3.35" width="15.61" height="23.98"/><path class="cls-3" d="M3.35,7.81a.56.56,0,0,0,.39-.17L6,5.41a.56.56,0,0,0,0-.79.57.57,0,0,0-.79,0L3,6.86a.54.54,0,0,0,0,.78A.56.56,0,0,0,3.35,7.81Z"/><path class="cls-3" d="M3.35,10.6a.56.56,0,0,0,.39-.17L4.86,9.32a.57.57,0,0,0,0-.79.56.56,0,0,0-.79,0L3,9.64a.57.57,0,0,0,.4,1Z"/><path class="cls-3" d="M5.18,7.41a.59.59,0,0,0-.16.4.57.57,0,0,0,.16.39.6.6,0,0,0,.4.17A.58.58,0,0,0,6,8.2a.57.57,0,0,0,.16-.39.56.56,0,0,0-1-.4Z"/><path class="cls-3" d="M6.3,7.09a.54.54,0,0,0,.39.16.57.57,0,0,0,.4-.16L8.76,5.41a.56.56,0,0,0,0-.79.57.57,0,0,0-.79,0L6.3,6.3A.56.56,0,0,0,6.3,7.09Z"/><path class="cls-3" d="M8,7.41l-5,5a.56.56,0,0,0,.4,1,.54.54,0,0,0,.39-.16l5-5a.56.56,0,0,0,0-.79A.57.57,0,0,0,8,7.41Z"/><path class="cls-3" d="M9.08,6.3a.57.57,0,0,0-.16.39.59.59,0,0,0,.16.4.61.61,0,0,0,.4.16A.55.55,0,0,0,10,6.69a.57.57,0,0,0-.16-.39A.59.59,0,0,0,9.08,6.3Z"/><path class="cls-3" d="M11.55,4.62a.57.57,0,0,0-.79,0l-.56.56a.56.56,0,0,0,.4,1A.54.54,0,0,0,11,6l.56-.56A.56.56,0,0,0,11.55,4.62Z"/><path class="cls-4" d="M11.15,2.23H7.81a.56.56,0,0,1-.56-.56.55.55,0,0,1,.56-.55h3.34a.55.55,0,0,1,.56.55A.56.56,0,0,1,11.15,2.23Z"/><path class="cls-5" d="M16.17,2.23h-.56a.56.56,0,0,1-.55-.56.55.55,0,0,1,.55-.55h.56a.55.55,0,0,1,.56.55A.56.56,0,0,1,16.17,2.23Z"/><path class="cls-5" d="M13.94,2.23h-.56a.56.56,0,0,1-.55-.56.55.55,0,0,1,.55-.55h.56a.55.55,0,0,1,.56.55A.56.56,0,0,1,13.94,2.23Z"/><path class="cls-4" d="M10.87,30.67H8.09a.84.84,0,0,1-.84-.83h0A.85.85,0,0,1,8.09,29h2.78a.85.85,0,0,1,.84.84h0A.84.84,0,0,1,10.87,30.67Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.83 32.52"><defs><style>.cls-1{fill:#ffd039;}.cls-2{fill:#f4b70c;}.cls-3{fill:#ffbb64;}.cls-4{fill:#ffae47;}.cls-5{fill:#ffdf65;}.cls-6{fill:#ffcd2c;}.cls-7{fill:#ffa035;}.cls-8{fill:#f78819;}</style></defs><title>Asset 9002-cup</title><g id="Layer_2" data-name="Layer 2"><g id="Livello_1" data-name="Livello 1"><path class="cls-1" d="M31.05,5.85h0a3.61,3.61,0,0,0-2.83-1.36H24.84a.47.47,0,0,0-.47.48V7.05a.47.47,0,0,0,.47.48h3.38a.56.56,0,0,1,.45.22.58.58,0,0,1,.11.46,9,9,0,0,1-1.93,4,5.79,5.79,0,0,1-2.37,1.58.46.46,0,0,0-.31.34,8.32,8.32,0,0,1-.84,2.26.46.46,0,0,0,0,.5.46.46,0,0,0,.39.2h.08a9,9,0,0,0,5.27-2.83,11.8,11.8,0,0,0,2.64-5.33A3.61,3.61,0,0,0,31.05,5.85Z"/><path class="cls-2" d="M27,12.28V12l-.15.16a5.79,5.79,0,0,1-2.37,1.58.46.46,0,0,0-.31.34,8.32,8.32,0,0,1-.84,2.26.46.46,0,0,0,0,.5.46.46,0,0,0,.39.2h.08a10,10,0,0,0,2.22-.65A9.45,9.45,0,0,0,27,12.28Z"/><path class="cls-2" d="M24.37,5V7.05a.47.47,0,0,0,.47.48H27v-3H24.84A.47.47,0,0,0,24.37,5Z"/><path class="cls-3" d="M19.5,28.05c-.14-.11-1.42-1.18-1.58-7a.49.49,0,0,0-.17-.36.54.54,0,0,0-.39-.1,8.66,8.66,0,0,1-1.44.13h0a8.69,8.69,0,0,1-1.45-.13.54.54,0,0,0-.39.1.49.49,0,0,0-.17.36c-.16,5.8-1.44,6.87-1.58,7a.44.44,0,0,0-.32.5.51.51,0,0,0,.5.42h6.81a.51.51,0,0,0,.5-.42A.44.44,0,0,0,19.5,28.05Z"/><path class="cls-4" d="M19.5,28.05c-.14-.11-1.42-1.18-1.58-7a.49.49,0,0,0-.17-.36.54.54,0,0,0-.39-.1,8.66,8.66,0,0,1-1.44.13h0l-.46,0a.48.48,0,0,1,.16.35c.16,5.8,1.43,6.87,1.58,7a.44.44,0,0,1,.32.5A.51.51,0,0,1,17,29h2.31a.51.51,0,0,0,.5-.42A.44.44,0,0,0,19.5,28.05Z"/><path class="cls-1" d="M.79,5.85h0A3.58,3.58,0,0,1,3.61,4.49H7A.47.47,0,0,1,7.46,5V7.05A.47.47,0,0,1,7,7.53H3.61a.56.56,0,0,0-.45.22.58.58,0,0,0-.11.46,9,9,0,0,0,1.93,4,5.79,5.79,0,0,0,2.37,1.58.46.46,0,0,1,.31.34,8.32,8.32,0,0,0,.84,2.26.46.46,0,0,1,0,.5.46.46,0,0,1-.39.2H8a9,9,0,0,1-5.27-2.83A11.8,11.8,0,0,1,.09,8.89,3.58,3.58,0,0,1,.79,5.85Z"/><path class="cls-2" d="M4.83,12.28V12l.15.16a5.79,5.79,0,0,0,2.37,1.58.46.46,0,0,1,.31.34,8.32,8.32,0,0,0,.84,2.26.46.46,0,0,1,0,.5.46.46,0,0,1-.39.2H8a10.16,10.16,0,0,1-2.22-.65A9.45,9.45,0,0,1,4.83,12.28Z"/><path class="cls-2" d="M7.46,5V7.05A.47.47,0,0,1,7,7.53H4.83v-3H7A.47.47,0,0,1,7.46,5Z"/><path class="cls-5" d="M24.84,2.76H7a.47.47,0,0,0-.48.48v9a9.41,9.41,0,1,0,18.81,0v-9A.47.47,0,0,0,24.84,2.76Z"/><path class="cls-6" d="M24.84,2.76H22.62a.47.47,0,0,1,.47.48v9a9.41,9.41,0,0,1-8.29,9.34,8.32,8.32,0,0,0,1.12.07,9.42,9.42,0,0,0,9.4-9.41v-9A.47.47,0,0,0,24.84,2.76Z"/><path class="cls-7" d="M20.06,11.17a1.05,1.05,0,0,0,.27-1.08,1,1,0,0,0-.85-.71l-1.76-.26a.08.08,0,0,1-.07,0l-.79-1.6a1,1,0,0,0-.94-.58h0a1,1,0,0,0-.95.58l-.79,1.6a.08.08,0,0,1-.07,0l-1.76.26a1,1,0,0,0-.85.71,1.05,1.05,0,0,0,.27,1.08L13,12.41a.1.1,0,0,1,0,.09l-.3,1.75a1.05,1.05,0,0,0,1.53,1.11l1.57-.83H16l1.57.83a1.11,1.11,0,0,0,.49.12,1.07,1.07,0,0,0,.62-.2,1,1,0,0,0,.42-1l-.3-1.75a.14.14,0,0,1,0-.09Z"/><path class="cls-8" d="M19.48,9.38,19,9.31,16.74,11.5a.57.57,0,0,0-.17.51l.52,3.11.44.24a1.11,1.11,0,0,0,.49.12,1.07,1.07,0,0,0,.62-.2,1,1,0,0,0,.42-1l-.3-1.75a.14.14,0,0,1,0-.09l1.27-1.24a1.05,1.05,0,0,0,.27-1.08A1,1,0,0,0,19.48,9.38Z"/><path class="cls-5" d="M25.12,31.89A5.14,5.14,0,0,0,20.43,28h-9a5.14,5.14,0,0,0-4.69,3.88.47.47,0,0,0,.07.43.49.49,0,0,0,.39.2h17.5a.48.48,0,0,0,.38-.2A.47.47,0,0,0,25.12,31.89Z"/><path class="cls-6" d="M25.12,31.89A5.14,5.14,0,0,0,20.43,28H16.77a5.14,5.14,0,0,1,4.69,3.88.5.5,0,0,1-.07.43.49.49,0,0,1-.39.2h3.67a.48.48,0,0,0,.38-.2A.47.47,0,0,0,25.12,31.89Z"/><path class="cls-1" d="M25.62,0H6.21a1.86,1.86,0,0,0,0,3.71H25.62a1.86,1.86,0,0,0,0-3.71Z"/><path class="cls-2" d="M25.62,0H23.46a1.86,1.86,0,1,1,0,3.71h2.16a1.86,1.86,0,0,0,0-3.71Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1 @@
var configuration_map = {"notificationRuleList":[],"config":{"enableNotification":true},"passKey":"{}"};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
!function(){"use strict";"undefined"!=typeof PushSubscriptionOptions&&PushSubscriptionOptions.prototype.hasOwnProperty("applicationServerKey")||void 0!==window.safari&&void 0!==window.safari.pushNotification?function(){const n=document.createElement("script");n.src="https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.page.es6.js?v=160510",n.defer=!0,document.head.appendChild(n)}():function(){let n="Incompatible browser.";"Apple Computer, Inc."===navigator.vendor&&navigator.maxTouchPoints>0&&(n+=" Try these steps: https://tinyurl.com/bdh2j9f7"),console.info(n)}()}();
//# sourceMappingURL=OneSignalSDK.page.js.map
@@ -0,0 +1,220 @@
var mult_send = 0;
function Contest_Send(){
mult_send = mult_send + 1;
PreparaContestSend('senduscontestform',false);
if (mult_send == 1)
{
AJAXReqContestSend("POST","send_us_contest.php",true);
}
}
function PreparaContestSend(nome,ele){
stringa = "";
var form = document.forms[nome];
var numeroElementi = form.elements.length;
for(var i = 0; i < numeroElementi; i++){
nmfrm = form.elements[i].name;
if(i < numeroElementi-1)
{
stringa += form.elements[i].name+"="+encodeURIComponent(form.elements[i].value)+"&";
}
else
{
stringa += form.elements[i].name+"="+encodeURIComponent(form.elements[i].value);
}
}
}
function AJAXReqContestSend(method,url,bool){
if(window.XMLHttpRequest){
myReq = new XMLHttpRequest();
} else
if(window.ActiveXObject){
myReq = new ActiveXObject("Microsoft.XMLHTTP");
if(!myReq){
myReq = new ActiveXObject("Msxml2.XMLHTTP");
}
}
if(myReq){
myReq.onreadystatechange = state_ContestSend;
myReq.open(method,url,bool);
myReq.setRequestHeader("Content-Type","application/x-www-form-urlencoded; charset=UTF-8");
myReq.send(stringa);
}else{
alert("Impossibilitati ad usare AJAX");
}
}
function state_ContestSend(bReload){
if (myReq.readyState==4){
mult_send = 0;
if (myReq.status==200){
ResponseContestSend(myReq.responseText);
}
else {
if (bDebug) {alert("Problem retrieving XML data");}
}
}
}
function ResponseContestSend(sResponse){
var vetResp = sResponse.split('|');
if (vetResp[0].toUpperCase() == 'OK')
{
if (MM_findObj("contest_name_msg"))
{
DisplayHTMLData(MM_findObj('contest_name_msg'), '&nbsp;');
}
if (MM_findObj("contest_video_msg"))
{
DisplayHTMLData(MM_findObj('contest_video_msg'), '&nbsp;');
}
if (MM_findObj("send_box_contest"))
{
MM_findObj("send_box_contest").style.display='none';
}
if (MM_findObj("send_box_contest_response"))
{
MM_findObj("send_box_contest_response").style.display='block';
}
}
else
{
if (MM_findObj("contest_name_msg"))
{
DisplayHTMLData(MM_findObj('contest_name_msg'), '&nbsp;');
}
if (MM_findObj("contest_video_msg"))
{
DisplayHTMLData(MM_findObj('contest_video_msg'), '&nbsp;');
}
for (b=1; b<vetResp.length; b++)
{
var f = vetResp[b].split(';');
var fldcont = f[0];
var msgcont = f[1];
if (fldcont == 'contest_name')
{
DisplayHTMLData(MM_findObj(fldcont + '_msg'), msgcont);
}
if (fldcont == 'contest_video')
{
DisplayHTMLData(MM_findObj(fldcont + '_msg'), msgcont);
}
}
}
}
var HTTP_FIRSTAUCT_URL = new String ('first_auct.php');
var xmlhttpFirstAuct = null;
function FirstAuct() {
var sUrlFirstAuct = HTTP_FIRSTAUCT_URL + "?chk=" + new Date().valueOf();
if (xmlhttpFirstAuct) {
if ((xmlhttpFirstAuct.readyState != 4) && (xmlhttpFirstAuct.readyState != 0)) {
return true;
}
}
try
{
if (window.XMLHttpRequest){
xmlhttpFirstAuct = new XMLHttpRequest();
} else if (window.ActiveXObject){
xmlhttpFirstAuct = new ActiveXObject("Microsoft.XMLHTTP");
}
if (xmlhttpFirstAuct != null){
xmlhttpFirstAuct.onreadystatechange = state_FirstAuct;
xmlhttpFirstAuct.open("GET",sUrlFirstAuct,true);
xmlhttpFirstAuct.send(null);
return true;
} else {
if (bDebug)
{
alert("Your browser does not support XMLHTTP.");
}
return false;
}
}
catch (e) {
xmlhttpFirstAuct = null;
if (bDebug) alert('Errore in loadXMLDocElenco');
}
finally {}
}
function state_FirstAuct(){
if (xmlhttpFirstAuct.readyState==4){
if (xmlhttpFirstAuct.status!=200){
if (bDebug) {
alert("Problem retrieving XML data");
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,129 @@
btn-promo {
border-radius: 3px;
background: linear-gradient(rgb(82, 157, 253), rgb(46, 114, 202));
background: -moz-linear-gradient(rgb(82, 157, 253), rgb(46, 114, 202));
background: -webkit-linear-gradient(rgb(82, 157, 253), rgb(46, 114, 202));
background: -o-linear-gradient(rgb(82, 157, 253), rgb(46, 114, 202));
background: -ms-linear-gradient(rgb(82, 157, 253), rgb(46, 114, 202));
color: #fff;
}
.btn.btn-promo:hover {
background: linear-gradient(rgb(117, 175, 250), rgb(53, 124, 216));
background: -moz-linear-gradient(rgb(117, 175, 250), rgb(53, 124, 216));
background: -webkit-linear-gradient(rgb(117, 175, 250), rgb(53, 124, 216));
background: -o-linear-gradient(rgb(117, 175, 250), rgb(53, 124, 216));
background: -ms-linear-gradient(rgb(117, 175, 250), rgb(53, 124, 216));
color: #fff;
}
.mCSB_inside > .mCSB_container {
margin-right: 0px;
}
.mCSB_scrollTools .mCSB_draggerRail {
width: 6px;
background-color: #e2e2e2;
}
.mCSB_scrollTools .mCSB_draggerContainer {
left: 10px;
}
.mCSB_scrollTools .mCSB_dragger .mCSB_dragger_bar {
background-color: #20cb9a !important;
width: 100%;
}
.loader {
margin: 10px auto;
border: 5px solid #f3f3f3;
/* Light grey */
border-top: 5px solid #20cb9a;
/* Blue */
border-radius: 50%;
width: 20px;
height: 20px;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
.stopScroll{
overflow: hidden !important;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media screen and (max-width: 384px) {
#prod_win_cont_modal h3 {
padding-left: 30px;
padding-right: 30px;
}
}
@media screen and (max-width: 320px) {
.prod_won__2 {
margin-left: 1px !important;
margin-right: 1px !important;
}
#prod_win_cont_modal .col-xs-6{
padding-left: 5px;
padding-right: 5px;
}
}
#modal iframe {
width: 99%;
}
#myModal3 .modal-dialog, #myModal2 .modal-dialog {
margin: 30px auto;
}
.settingBox form div {
border-bottom: 1px solid #efefef;
padding: 15px;
font-weight: 500;
font-size: 12px;
color: #818181;
}
@media screen and (max-width: 991px) {
#menuModal .show {
display: block;
}
#menuModal .modal-header {
height: auto;
}
#menuModal .height {
height: 0px;
}
.parentOverflowY {
overflow-y: hidden;
}
}
#notifBoxContainer .mCSB_container {
top: 0px;
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,206 @@
const AuctionsBidManage = (function() {
const _defaultPart = "divAsta";
let _nAstePerBonus = 10;
let _limiteAsteVinte = 10;
let _nAstePuntataVinte = 0;
let _percentualeBonus = 0;
let _nPuntateVinteOggi = 0;
let _nAsteConfermate = 0;
let _nPuntateRiscattate = 0;
let _nPuntateDaRiscattare = 0;
let _initialized = false;
let _defaultValidUntil = null;
let _viewSlot = false;
function _defaultObj() {
return {
auctions: {},
nAsteConfermate: 0,
percentualeBonus: 0,
limiteAsteVinte: 10,
nAstePerBonus: 10,
validUntil: getDefaultValidUntil()
}
}
/**
* Aggiunge una nuova asta all'oggetto delle aste di puntata vinte in un giorno
* @param idAuction {int}
*/
function add(idAuction){
let result = get();
let retrievedObject = JSON.parse(result);
let auctionBidWin = retrievedObject === null ? _defaultObj() : retrievedObject ;
let auctionElement = document.getElementById(_defaultPart + idAuction);
let creditValue = parseInt(auctionElement.getAttribute('data-credit-value')) > 0 ? parseInt(auctionElement.getAttribute('data-credit-value')) : 0;
if(creditValue > 0 && Object.keys(auctionBidWin.auctions).length <= auctionBidWin.limiteAsteVinte){
// let obj =
// {
// idAuction: idAuction,
// value: creditValue
// }
// ;
//
// if(!auctionBidWin.auctions.hasOwnProperty(idAuction)){
// auctionBidWin.auctions[idAuction] = obj;
// }
// let newObj = JSON.stringify(auctionBidWin);
//
// localStorage.setItem("auctionBidWin", newObj);
getRemoteData();
}
return;
}
function getDefaultValidUntil(){
return _defaultValidUntil;
}
function setDefaultValidUntil(untilTimestamp){
_defaultValidUntil = untilTimestamp;
}
/**
* Ritornano le informazioni salvate nel localStorage
* @returns {string}
*/
function get(){
return localStorage.getItem('auctionBidWin');
}
/**
* Rimuove dal localStorage
*/
function remove(){
localStorage.removeItem('auctionBidWin');
}
function getRemoteData(callback = null){
fetch('./ajax/get_auction_bids_info_banner.php',{
method: "GET"
})
// gestisci il successo
.then(response => response.json()) // converti a json
.then(function (data) {
let obj = {
auctions: data.auctions,
nAsteConfermate: data.nAsteConfermate,
nAsteVinte: data.nAsteVinte,
nPuntateRiscattate: data.nPuntateRiscattate,
nPuntateDaRiscattare: data.nPuntateDaRiscattare,
limiteAsteVinte: data.limiteAsteVinte,
nAstePerBonus: data.nAstePerBonus,
percentualeBonus: data.percentualeBonus,
validUntil: data.validUntil,
viewSlot: data.viewSlot,
extraSlots: data.extraSlots, //un elenco degli slots non scaduti e non aperti
nPuntateBonus: data.nPuntateBonus
};
let newObj = JSON.stringify(obj);
localStorage.setItem("auctionBidWin", newObj);
if(callback !== null){
callback();
}
})
.catch(err => console.log('Request Failed', err)); // gestisci gli errori
}
function retriveInfoComponent(){
let result = JSON.parse(get());
if(result) {
_nAstePuntataVinte = result.nAsteVinte;
_limiteAsteVinte = result.limiteAsteVinte;
_nAsteConfermate = result.nAsteConfermate;
_nPuntateDaRiscattare = result.nPuntateDaRiscattare;
_nPuntateRiscattate = result.nPuntateRiscattate;
_nAstePerBonus = result.nAstePerBonus;
_percentualeBonus = result.percentualeBonus;
_viewSlot = result.viewSlot;
_nPuntateVinteOggi = result.nPuntateDaRiscattare + result.nPuntateRiscattate;
/*if (_percentualeBonus > 0) {
let valorePercentualeBonus = ((_nPuntateVinteOggi * _percentualeBonus) / 100);
_nPuntateVinteOggi = _nPuntateVinteOggi + valorePercentualeBonus;
}*/
let asteRimanentiPerBonus = _nAstePerBonus - _nAstePuntataVinte;
return {
auctions: result.auctions,
asteRimanentiPerBonus: asteRimanentiPerBonus,
nAstePerBonus: _nAstePerBonus,
nAstePuntataVinte: _nAstePuntataVinte,
percentualeBonus: _percentualeBonus,
nPuntateVinteOggi: parseInt(_nPuntateVinteOggi),
limiteAsteVinte: _limiteAsteVinte,
nAsteConfermate: _nAsteConfermate,
nPuntateDaRiscattare: _nPuntateDaRiscattare,
nPuntateRiscattate: _nPuntateRiscattate,
viewSlot: _viewSlot,
extraSlots: result.extraSlots,
nPuntateBonus: result.nPuntateBonus
}
}
}
/**
*
* @param auctionId
* @returns {boolean}
*/
function searchByAuctionId(auctionId){
let data = retriveInfoComponent();
let controllo = false;
if(data == undefined || data == null ){ return; }
let keys = Object.keys(data.auctions);
for(let i= 0; i <= keys.length; i++){
if(keys[i] === auctionId){
return true;
}
}
return false;
}
function initRemoteData(){
if(!_initialized){
getRemoteData();
setInterval(function (){
getRemoteData();
}, 1000 * 60);
}
_initialized = true;
}
return {
add,
get,
remove,
getRemoteData,
retriveInfoComponent,
initRemoteData,
setDefaultValidUntil,
searchByAuctionId
}
})();
@@ -0,0 +1,927 @@
#wrapBonusSection{
width: 100%;
background-color: #fff;
margin-bottom: 20px;
-webkit-box-shadow: 0px 2px 5px 0px rgba(0,0,0,0.2);
-moz-box-shadow: 0px 2px 5px 0px rgba(0,0,0,0.2);
box-shadow: 0px 2px 5px 0px rgba(0,0,0,0.2);
border-top: 1px solid #d0d0d0;
display: none;
cursor: pointer;
}
#wrapBonusSection.visible{
display: block;
}
#BonusSection{
display: flex;
justify-content: space-between;
width: 65%;
min-height: 40px;
margin: 0 auto;
align-items: center;
margin-bottom: -25px;
}
@media (max-width: 576px) {
#BonusSection{
margin-bottom: 0;
}
}
#BonusSection .wrap-msg-bonus, #BonusSection .pt-2 .wrap-progress, #BonusSection .pt-1 .wrap-progress, #BonusSection .pt-3 .wrap-progress, #BonusSection .pt-3 .wrap-bids, #auctionBidsModal .wrap-msg-bonus, #auctionBidsModal .wrap-msg-bonus .wrap-progress, #bidsBonusSection #section2 .wrap-progress, #bidsBonusSection #section3 .wrap-progress{
display: flex;
justify-content: center;
align-items: center;
}
#bidsBonusSection #section2 .wrap-progress{
font-size: 14px;
}
#auctionBidsModal #countdownAddSlot{
font-weight: bold;
color: #55bc62;
}
#auctionBidsModal .wrap-msg-bonus{
margin-bottom: 25px;
}
#BonusSection .pt-2 .item, #BonusSection .pt-3 .item{
margin: 2px;
}
#BonusSection .pt-2, #BonusSection .pt-1, #BonusSection .pt-3, #auctionBidsModal .wrap-pt2{
display: flex;
justify-content: center;
align-items: center;
}
#auctionBidsModal .wrap-pt2 .bonus-obtained{
font-size: 12px;
color: #6F6F6F;
font-weight: bold;
margin-top: 3px;
display: none;
}
#auctionBidsModal .wrap-pt2{
justify-content: space-around;
align-items: flex-start;
margin-top: 20px;
border-top: 1px solid #DBDBDB;
padding-top: 20px;
font-size: 13px;
margin-bottom: 10px;
}
@media (max-width: 340px) {
#auctionBidsModal .wrap-pt2{
font-size: 12px;
}
}
#auctionToBonusModal{
font-size: 15px;
color: #333;
}
#BonusSection .text{
color: #6D6D6D;
font-size: 15px;
margin: auto 4px;
}
#BonusSection .pt-2 img{
height: 29px;
}
#BonusSection .pt-2 .img-emoji img, #auctionBidsModal .img-emoji img {
height: 18px;
width: 18px;
margin-left: -10px;
margin-top: -3px;
}
#auctionBidsModal .img-emoji img{
width: 24px;
height: 24px;
}
#auctionBidsModal .img-emoji{
z-index: 9;
margin-left: -3px;
margin-right: 2px;
}
#BonusSection .pt-dx img, #auctionBidsModal .pt-dx img, #auctionBidsModal .pt-center img{
width: 16px;
}
#BonusSection .pt-2 .wrap-progress .progress{
margin-left: 5px;
background-color: #D1D1D1;
}
#auctionBidsModal .progress{
width: 56px;
height: 24px;
margin: 0 6px;
position: relative;
background-color: #D1D1D1;
border-radius: 20px;
}
#auctionBidsModal .progress{
width: 110px;
height: 22px;
}
#auctionBidsModal .pt-1 .progress{
height: 11px;
}
#BonusSection .progress, #auctionBidsModal .progress{
width: 60px;
height: 13px;
margin-bottom: 0px;
}
#auctionBidsModal .progress{
width: 90px;
}
#BonusSection #countdown-bonus{
width: 60px;
color: #fff;
background-color: #FF0658;
font-weight: bold;
font-size: 11px;
padding: 0px 4px;
height: 16px;
border-radius: 5px;
}
#BonusSection #bonus-earned, #BonusSection #bonus-active-all{
display: none;
font-weight: bold;
margin-right: 15px;
}
@media (max-width: 576px) {
#BonusSection #bonus-earned, #BonusSection #bonus-active-all{
font-size: 12px;
}
}
#BonusSection #bonus-active-all{
color: #55bc62;
}
#BonusSection .progress .progress-bar, #auctionBidsModal .pt-1 .progress .progress-bar, #auctionBidsModal #bidsBonusSection #section2 .progress .progress-bar, #auctionBidsModal .progress .progress-bar{
background: rgb(4,170,176);
background: linear-gradient(90deg, rgba(4,170,176,1) 0%, rgba(7,206,173,1) 100%);
box-shadow: none;
}
#BonusSection .progress .progressbar-text{
position: absolute;
top: 2px;
height: 22px;
color: #000;
width: 100%;
left: 0;
margin: 0;
font-size: 16px;
font-weight: bold;
}
#auctionBidsModal .progress .progressbar-text{
font-size: 15px;
top: 0;
}
#todayBids, #auctionBidsModal{
font-size: 16px;
color: #333;
}
#auctionBidsModal{
font-weight: normal;
}
#auctionToBonus.active{
font-weight: bold;
color: #000;
}
#confirmedAuctionWithBonus{
display: none;
margin-left: 5px;
}
#confirmedAuctionWithBonus .value{
font-weight: bold;
}
.wrap-bonus-mobile{
display: flex;
justify-content: center;
align-items: center;
}
.wrap-bonus-mobile .wrap-bonus-value{
font-size: 14px;
color: #504E4E;
margin-left: 5px;
}
#auctionBidsModal .wrap-bonus-value{
margin-right: 2px;
}
#BonusSection .pt-3 img{
width: 15px;
}
#BonusSection .wrap-title-bonus{
display: flex;
}
#BonusSection .wrap-title-bonus .icon-check{
width: 15px;
display: none;
margin-right: 4px;
}
#BonusSection .pt-2 #countdown-bonus{
display: none;
}
@media (max-width: 1040px) {
#BonusSection{
width: 100%;
justify-content: space-around;
min-height: 50px;
}
#BonusSection .pt-1, #BonusSection .pt-2, #BonusSection .pt-3{
flex-direction: column;
}
#BonusSection .pt-3 .wrap-bids{
display: flex;
justify-content: center;
align-items: center;
}
#BonusSection .text {
font-size: 12px;
}
#BonusSection .pt-2 img{
margin-right: 5px;
height: 18px;
width: 10px;
}
#BonusSection .pt-1 .progress {
width: 90px;
height: 10px;
}
#todayBids{
font-size: 14px;
}
#BonusSection .pt-3 .item{
margin: 0;
}
#BonusSection .pt-3 img{
width: 13px;
margin-top: -1px;
margin-left: 3px;
}
#BonusSection .wrap-msg-bonus{
flex-direction: column;
justify-content: flex-start;
align-items: baseline;
}
.wrap-bonus-mobile{
display: flex;
justify-content: center;
align-items: center;
}
}
@media (max-width: 576px) {
#BonusSection {
min-height: 45px;
}
}
@media (max-width: 360px) {
#BonusSection .text {
font-size: 11px;
}
}
#auctionBidsModal .pt-left .wrap-progress, #auctionBidsModal .pt-dx .wrap-bids, #auctionBidsModal .pt-center .wrap-bids{
display: flex;
justify-content: center;
align-items: center;
margin-top: 5px;
font-weight: bold;
}
#auctionBidsModal .pt-center .item, #auctionBidsModal .pt-dx .item{
margin-left: 1px;
margin-right: 1px;
}
#auctionBidsModal .pt-center .item img, #auctionBidsModal .pt-dx .item img{
margin-left: 2px;
margin-right: 2px;
margin-top: -5px;
}
#auctionToGoModal{
font-size: 14px;
font-weight: bold;
}
@media (max-width: 340px) {
#auctionBidsModal .wrap-pt2{
font-size: 12px;
}
#auctionToGoModal, #todayBidsModal, #todayBidsPayedModal{
font-size: 13px;
}
}
#todayBidsModal, #todayBidsPayedModal{
font-size: 14px;
font-weight: bold;
}
#loaderAuctionBids{
min-height: 40px;
text-align: center;
padding: 5px;
font-size: 20px;
}
#auctionToGo{
color: #000;
margin-left: 5px;
}
.loader-data{
display: block;
position: relative;
margin-right: 0px !important;
margin-left: 0px !important;
}
.loader-data::before{
content: "";
background-color: #eaeaea;
display: block;
width: 100%;
height: 22px;
position: absolute;
}
@media (max-width: 576px) {
.loader-data{
margin-top: 2px !important;
margin-bottom: 2px !important;
}
.loader-data::before{
height: 16px;
min-width: 20px;
}
}
#BonusSection .pt-1, #BonusSection .pt-2, #BonusSection .pt-3{
position: relative;
}
.loader-data img{
display: none !important;
}
.wrap-countdown-auctionBidsModal{
color: #55BC62;
font-weight: bold;
}
#auctionBidsModal .wrapTitle{
margin: 20px auto 10px;
display: flex;
align-items: center;
justify-content: center;
}
#auctionBidsModal .modal-body{
padding: 0;
}
#auctionBidsModal .contentModal #bidsBonusSection .content, #auctionBidsModal .contentModal #rankingBonusSection{
font-size: 16px;
text-align: center;
margin-top: 15px;
padding: 15px;
}
#auctionBidsModal .contentModal #bidsBonusSection .content{
margin-top: 0;
padding: 0;
}
#auctionBidsModal .contentModal #bidsBonusSection .content.parent-content-div {
padding: 0 0 5px 0;
}
#tabsSection{
display: flex;
align-items: center;
}
@media (max-width: 576px) {
#tabsSection{
font-size: 14px;
}
}
#tabsSection .pt-1{
width: 55%;
}
#tabsSection .pt-2{
width: 40%;
}
#tabsSection .pt-1, #tabsSection .pt-2{
padding: 8px 18px;
border-bottom: 1px solid #BCBCBC;
text-align: center;
font-size: 14px;
}
#tabsSection .pt-1 .fa, #tabsSection .pt-2 .fa{
margin-right: 5px;
}
#tabsSection .pt-3{
width: 10%;
padding: 5px 10px;
border-bottom: 1px solid #BCBCBC;
}
button[aria-label='Close'] span{
font-size: 26px;
}
@media (max-width: 576px) {
#tabsSection .pt-3{
padding: 4px 10px;
}
#tabsSection .pt-1, #tabsSection .pt-2{
font-size: 12px;
padding: 8px 10px;
}
button[aria-label='Close'] span{
font-size: 25px;
}
}
#tabsSection .pt-1.active, #tabsSection .pt-2.active, #tabsSection .pt-3.active{
border-color: #2F80ED;
}
#tabsSection .pt-1.active a, #tabsSection .pt-2.active a, #tabsSection .pt-3.active a, #tabsSection .pt-1.active a:hover, #tabsSection .pt-2.active a:hover{
color: #2F80ED;
font-weight: bold;
text-decoration: none;
}
#tabsSection .pt-1 a, #tabsSection .pt-2 a{
color: #7d7d7d;
}
#tabsSection .pt-1 a:hover, #tabsSection .pt-2 a:hover{
text-decoration: none;
font-weight: normal;
color: #2F80ED;
}
#rankingBonusSection{
display: none;
}
#bidsBonusSection #section2 .box-congrats,
#bidsBonusSection #section3 .box-congrats{
margin-top: 12px;
}
#bidsBonusSection #section2, #bidsBonusSection #section3{
display: none;
}
#bidsBonusSection #section2 .titleModal{
font-size: 20px;
}
#auctionBidsModal .wrap-credit-bonus{
display: inline-block;
}
#auctionBidsModal #bidsBonusSection .wrap-content{
display: flex;
flex-direction: column;
}
#bidsBonusSection #section2 .summary-body #countdown-bonus{
font-size: 14px;
}
#bidsBonusSection #section2 .summary-body .countdown{
color: #FF0658;
}
#bidsBonusSection #section2 .summary-body #countdownForBonus{
font-weight: bold;
}
#bidsBonusSection #section2 .summary{
margin-top: 30px;
}
#bidsBonusSection #section2 .summary-body{
width: 250px;
margin: -18px auto 25px;
border: 1px solid #000;
border-radius: 5px;
padding: 20px 15px;
box-shadow: 2px 2px 3px 1px rgba(208, 209, 213, 0.2), 0 2px 2px 1px rgba(220, 221, 224, 0.2);
-webkit-box-shadow: 2px 2px 3px 1px rgba(208, 209, 213, 0.2), 0 2px 2px 1px rgba(220, 221, 224, 0.2);
-moz-box-shadow: 2px 2px 3px 1px rgba(208, 209, 213, 0.2), 0 2px 2px 1px rgba(220, 221, 224, 0.2);
}
#bidsBonusSection #section3 .summaryTitle{
font-size: 20px;
font-weight: bold;
margin-top: 40px;
}
#bidsBonusSection #section3 .summaryList img{
width: 18px;
}
#bidsBonusSection #section3 .summaryList ul{
text-align: left;
width: 300px;
margin: 5px auto 20px;
line-height: 30px;
}
.bottom-area{
font-size: 16px;
}
.bottom-area.highlight{
color: #FF0658;
font-weight: bold;
margin-top: 15px;
margin-bottom: -10px;
}
#auctionBidsModal #bidsBonusSection #bonusSection img{
width: 20px;
margin-top: -2px;
}
#auctionBidsModal #bidsBonusSection #bonusSection .bottomSection img{
width: 16px;
margin-right: 4px;
}
#auctionBidsModal #bidsBonusSection #bonusSection .bottomSection{
font-size: 16px;
margin-bottom: 15px;
color: #5F5F5F;
}
#bonusSection .btnConfirm{
font-size: 18px;
display: inline-block;
color: #333;
background-color: #fcc62d;
padding: 5px;
line-height: 25px;
border-radius: 5px;
font-weight: bold;
margin-bottom: 10px;
margin-top: 10px;
width: 95%;
text-decoration: none;
}
@media (max-width: 576px) {
#auctionBidsModal #bidsBonusSection #bonusSection .bottomSection {
font-size: 15px;
}
#bonusSection .btnConfirm{
font-size: 16px;
}
}
#rankingBonusSection .title{
font-size: 20px;
font-weight: bold;
margin-bottom: 5px;
}
#rankingBonusSection .subtitle{
font-size: 14px;
}
#rankingBonusSection #ranking{
padding: 0 10px;
}
#rankingBonusSection #ranking table{
margin-top:30px;
width: 100%;
}
#rankingBonusSection #ranking table td{
text-align: left;
}
#rankingBonusSection #ranking table .td1{
text-align: center;
font-weight: bold;
font-size: 14px;
}
#rankingBonusSection #ranking table .td1 img{
width: 38px;
}
#rankingBonusSection #ranking table td.td2{
font-size: 16px;
width: 70%;
padding: 8px 10px;
text-transform: capitalize;
}
#rankingBonusSection #ranking table td.td3{
width: 25%;
font-size: 14px;
font-weight: bold;
text-align: right;
}
#rankingBonusSection #ranking table td.td3 img{
width: 17px;
margin-left: 4px;
}
#auctionBidsModal #section1, #auctionBidsModal #section4{
display: none;
}
#auctionBidsModal #section1{
padding: 0px 20px;
}
#auctionBidsModal #section4 .sad{
width: 25px;
margin-bottom: 10px;
}
#BonusSection .img-lock, #BonusSection .img-lock-open{
display: none;
}
#BonusSection .img-lock img, #BonusSection .img-lock-open img{
width: 12px;
margin-left: 5px;
margin-top: -1px;
}
#BonusSection .pay-bids-counter{
margin-left: 5px;
color: #fff;
background-color: #FF0658;
border-radius: 40px;
width: 19px;
height: 19px;
text-align: center;
font-weight: bold;
font-size: 11px;
padding: 2px;
display: none;
}
#auctionBidsModal .wrap-new-daily-challenge.wrap2{
margin-top: 5px;
}
#auctionBidsModal .wrap-new-daily-challenge{
color: #2F80ED;
font-size: 16px;
font-weight: bold;
margin: 20px 60px 0px;
display: none;
}
@media (max-width: 576px) {
#auctionBidsModal .wrap-new-daily-challenge {
margin: 20px 40px 0;
}
}
#BonusSection .img-plus-not-active, #BonusSection .img-plus-active{
display: none;
}
#auctionBidsModal .extraSlots .slot-title .open-lock{
margin-top: -5px;
width: 15px;
margin-right: 3px;
}
#auctionBidsModal .extraSlots .slot-title span .fa{
font-size: 20px;
position: absolute;
margin-left: 5px;
}
#auctionBidsModal .extraSlots .wrap-content-slot{
padding: 10px 0 15px;
}
#auctionBidsModal .bonus-obtained{
display: none;
}
#auctionBidsModal .extraSlots{
border: none;
margin: -5px auto 0;
padding: 10px 15px;
text-align: center;
background-color: #f3f6f9;
border-radius: 0;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
#auctionBidsModal .extraSlots.avaible{
border-color: #55BC62;
}
#extraSlotTemplate{
display: none;
}
#wrapSlots{
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
#wrapSlots .box-extra-slot{
border: 1px solid #6F6F6F;
background-color: #f3f6f9;
padding: 10px;
border-radius: 5px;
width: 100px;
margin-top: 15px;
display: none;
}
.wrap-num-other-slot{
position: relative;
display: none;
font-weight: bold;
margin-top: 10px;
font-size: 12px;
}
.wrap-num-other-slot .reduce{
display: none;
position: absolute;
top: 0;
right: 20px;
color: #333;
text-decoration: underline;
}
.wrap-num-other-slot .open{
color: #55BC62;
text-decoration: underline;
display: none;
}
#wrapSlots .box-extra-slot .expire{
font-size: 10px;
font-weight: bold;
color: #6A6B6C;
margin-bottom: 5px;
}
#wrapSlots .box-extra-slot .expire img{
margin-left: 2px;
}
#wrapSlots .box-extra-slot .content{
display: flex;
justify-content: center;
}
#wrapSlots .box-extra-slot .content .slot-value{
font-size: 18px;
font-weight: bold;
color: #333;
}
#wrapSlots .box-extra-slot .wrap-cta .cta{
background-color: #B4B4B4;
color: #fff;
padding: 0px 10px;
font-size: 12px;
font-weight: bold;
width: 100%;
max-height: 23px;
}
#wrapSlots .box-extra-slot .wrap-cta .cta img{
margin-top: -2px;
margin-right: 2px;
}
.extraSlots .wrap-content-slot{
display: none;
}
.extraSlots .slot-title a{
width: 100%;
display: block;
color: #333;
text-decoration: none;
}
.extraSlots .slot-title{
color: #000;
font-size: 14px;
font-weight: bold;
}
.extraSlots .slot-content{
font-size: 12px;
}
.extraSlots .slot-content .beforeConfirmed, .extraSlots .slot-content .afterConfirmed{
display: none;
}
.wrap-extra-slots .box-noSlot .titleNoSlot{
color: #FE4E4E;
font-size: 14px;
font-weight: bold;
}
.wrap-extra-slots .box-noSlot .contentNoSlot{
font-size: 12px;
}
.wrap-extra-slots .box-noSlot .contentNoSlot img{
width: 17px;
margin-top: -3px;
margin-left: 3px;
}
.wrap-extra-slots .box-noSlot{
margin-top: 10px;
}
.wrap-extra-slots .box-noSlot{
display: none;
}
.wrap-extra-slots .box-noSlot .box-noextra-slot .no-extra-slot-content .slot-img img{
width: 20px;
opacity: 0.6;
margin-bottom: 5px;
}
.wrap-extra-slots .box-noSlot .box-noextra-slot .no-extra-slot-content{
font-size: 10px;
color: #333;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.wrap-extra-slots .box-noSlot .box-noextra-slot{
width: 92px;
margin: 20px auto;
height: 81px;
border: 1px solid #55BC62;
border-radius: 5px;
}
#wrapSlots.avaible .box-extra-slot{
border-color: #55BC62;
background-color: #EDF8EF;
}
#wrapSlots.avaible .box-extra-slot .wrap-cta .cta{
background-color: #55BC62;
}
.wrap-extra-slots{
display: none;
}
.wrap-extra-slots .title-extraSlot-blocked{
font-size: 18px;
font-weight: bold;
width: 100%;
margin: 0 0 10px;
display: none;
text-align: center;
}
.wrap-extra-slots .title-extraSlot-blocked img{
width: 18px;
margin-top: -5px;
}
#auctionBidsModal #bidsBonusSection .img-lock, #auctionBidsModal #bidsBonusSection .img-lock-open{
display: none;
}
#auctionBidsModal #bidsBonusSection .img-lock img, #auctionBidsModal #bidsBonusSection .img-lock-open img{
margin-left: 5px;
width: 14px;
margin-top: -5px;
}
#auctionBidsModal #differenzaAsteDaConfermare{
color: #fff;
background-color: #FF0658;
border-radius: 40px;
width: 23px;
height: 23px;
text-align: center;
font-weight: bold;
padding: 1px 2px;
display: inline-block;
font-size: 15px;
}
#auctionBidsModal #bonusEarned img{
width: 15px;
margin-left: 5px;
}
#auctionBidsModal .wrap-already-taken, #auctionBidsModal .bonus-yet-to-be-obtained{
display: none;
}
#auctionBidsModal .wrap-already-taken .txt-already-taken{
color: #797979;
}
#auctionBidsModal .bonus-yet-to-be-obtained{
color: #FF0658;
}
#auctionBidsModal #bonusEarned .wrap-details-bonus{
text-align: center;
margin: 7px 0;
}
#auctionBidsModal #modalConfirmSlotStopGame{
z-index: 11;
position: absolute;
top: 0;
bottom: 0;
height: 130px;
background-color: #fff;
width: 270px;
margin: auto;
left: 0;
right: 0;
padding: 15px;
border-radius: 5px;
text-align: center;
display: none;
}
#auctionBidsModal #modalConfirmSlotStopGame .contentButton .btn{
padding: 2px 13px;
font-size: 12px;
color: #fff;
font-weight: bold;
margin: 3px;
}
#auctionBidsModal #modalConfirmSlotStopGame .contentButton .btn-confirm{
background-color: #55BC62
}
#auctionBidsModal #modalConfirmSlotStopGame .contentButton .btn-cancel{
background-color: #BFBFBF;
}
#auctionBidsModal #modalConfirmSlotStopGame .contentButton{
display: flex;
justify-content: center;
margin-top: 10px;
}
#auctionBidsModal #modalConfirmSlotStopGame .content{
font-size: 12px;
text-align: center;
}
#auctionBidsModal #modalConfirmSlotStopGame .title{
color: #FF0202;
text-align: center;
font-size: 13px;
font-weight: bold;
margin-bottom: 5px;
}
#auctionBidsModal .overlayModalConfirmSlotStopGame{
background: rgba(0,0,0,0.2);
top: 0;
left: 0;
position: absolute;
width: 100%;
height: 100%;
display: none;
}
@@ -0,0 +1,113 @@
$(document).ready(function () {
window.myAuctionsControlDetail = new Array(); // array di oggetti deputato a contenere l'asta dove si sta autopuntando
window.myAuctionsControlDetail_lock = false; // flag to lock SetInterval execution
/*
*
* @returns {undefined}
* questa funzione si occupa di controllare ogni 2 secondi se l'asta (dettaglio) su cui c'è un'autopuntata sia realmente attive o c'è stato un blocco lato UI
*
*/
setInterval(
function () {
if (window.myAuctionsControlDetail_lock == false) {
window.myAuctionsControlDetail_lock = true; // lock setinterval execution
//console.log("--- Checking autobids..."); // FOR DEBUG
let isAuctionStarted_element = $(".auction-action-timer.auction-header-item-size.closed-timer"); // element not present if auction started
let callingAjax = false;
if (isAuctionStarted_element.length == 0) { // check element not present if auction started
let element_value = $('.auction-autobid-current-value'); // recupero il valore delle puntate rimanenti nell'asta
if (element_value.length > 0) {
let value = $(element_value[0]).text(); // recupero il valore delle puntate rimanenti nell'asta
//console.log("Puntate autobid = "+value); // FOR DEBUG
if (value > 0) {
let idasta = $('input.js-switch.autobid-switch').data("id"); // recupero l'id dell'asta
//console.log("Checking Asta: " + idasta); // FOR DEBUG
let timestamp = Date.now();
let myAuctions = {
idasta: idasta,
value: value,
timestamp: timestamp,
element_value: element_value
}
if (window.myAuctionsControlDetail.length == 0) { // controllo che questa asta non sia già nell'array
//console.log("Adding Asta in array."); // FOR DEBUG
window.myAuctionsControlDetail = myAuctions;
} else {
if (window.myAuctionsControlDetail.value != value) { // controllo che il valore sia cambiato per in modo da aggiornare le informazioni
//console.log("Value changed."); // FOR DEBUG
window.myAuctionsControlDetail = myAuctions;
} else {
//console.log("Checking time..."); // FOR DEBUG
// in questa condizione il valore non è cambiato dunque controllerò da quanto tempo non cambia
var diffMs = (Date.now() - window.myAuctionsControlDetail.timestamp);
//console.log("diffMs = "+diffMs); // FOR DEBUG
//var diffMins = Math.round(((diffMs % 86400000) % 3600000) / 60000); // minutes
var diffSecs = Math.round(((diffMs % 86400000) % 3600000) / 1000); // minutes
//console.log("diffSecs = "+diffSecs); // FOR DEBUG
// nel caso in cui la differenza è maggiore o uguale a 2 minuti invoco la funzione che si occuperà di spedire le informazioni lato backend
if (diffSecs >= 70) { // default 70 secs
callingAjax = true;
sentToVerification(myAuctions);
window.myAuctionsControlDetail = new Array(); // elimino l'asta dall'array
}
}
}
} else {
window.myAuctionsControlDetail = new Array(); // elimino l'asta dall'array
}
}
}
if (callingAjax === false) {
window.myAuctionsControlDetail_lock = false; // unlock setinterval execution
}
}
},
2000);
function sentToVerification(myAuctions) {
// funzione che serve ad inviare al backend l'asta attiva ma con valori di autopuntata fermi da 2 min
//console.log(myAuctions); // FOR DEBUG
$.ajax({
url: "check_autobid.php",
//dataType: json,
method: 'POST',
timeout: 10000, // default 10000
data : {
idasta: myAuctions.idasta,
value: myAuctions.value,
timestamp: myAuctions.timestamp
},
}).done(function (response) {
window.myAuctionsControlDetail_lock = false; // unlock setinterval execution
//console.log("response = " + response); // FOR DEBUG
$(myAuctions.element_value).text(response);
}).fail(function(jqXHR, textStatus){
if(textStatus === 'timeout') {
//console.log("Ajax timeout. Recall ajax."); // FOR DEBUG
sentToVerification(myAuctions);
}
});
}
});
@@ -0,0 +1,306 @@
function getTexts() {
"use strict"
return {
dialog_confirm: "Sei sicuro di voler rimuovere l\'AutoPuntata?",
autobid_active: "Hai attivato la funzione utilizzando le puntate prenotate",
autobid_not_active: "Attiva la funzione utilizzando le puntate prenotate",
autobid_add: "AGGIUNGI",
autobid_insert: "INSERISCI"
};
}
function enableAutobid() {
"use strict";
$(".auction-action-autobid-trigger")
.toggleClass("button-fucsia-flat", true)
.toggleClass("button-gray-flat", false)
.off('click')
.on('click', setAutobid);
$(".auction-action-autobid-input")
.attr('disabled', false)
.off("keyup").keyup(function (e) {
if (13 == e.which)
$(".auction-action-autobid-trigger").click();
$(".auction-action-autobid-mobile .auction-action-autobid-trigger").toggleClass("disable", $(this).val().length == 0);
});
}
function disableAutobid(reason) {
"use strict";
$(".auction-action-autobid-trigger")
.off('click')
.on('click', function () {
if (!reason)
return;
showErrorTooltip('.auction-action-autobid-trigger:eq(' + getAuctionSelector() + ')', {
title: reason,
html: true,
container: "body",
trigger: "manual",
placement: "top",
template: getTemplateTooltip("error")
}, 3000);
});
}
function unsetAutobid(evt) {
"use strict";
if ('undefined' == typeof window['autobid_switchery']) {
return;
}
if (!isSwitchEnabled()) {
if (evt) {
window._autoController.setAutobid('delete', null, cleanUpAutobidSwitch);
}
}
}
function cleanUpAutobidSwitch() {
"use strict"
$(".auction-autobid-button")
.toggleClass("active", false)
.find(".bi-autobid")
.toggleClass("bi-dark", true)
.toggleClass("bi-green", false);
$(".auction-action-bid-mobile .auction-autobid-current-value").empty();
setTimeout(function () {
if (!isSmartphoneDevice())
$('.auction-action-autobid-trigger').text(getTexts().autobid_insert);
}, 400);
updateAutobid(0);
$('.auction-action-autobid:not(.auction-seat-autobid) .autobid-switch-container, .auction-action-autobid-mobile .autobid-switch-container').hide();
}
function isSwitchEnabled() {
return 'undefined' != typeof window['autobid_switchery'] && window.autobid_switchery[isSmartphoneDevice() ? 1 : 0].isChecked();
}
function hideAutobid() {
"use strict";
$(".auction-action-autobid:visible").hide();
}
function showLoginAutobid() {
"use strict";
$(".auction-action-autobid-trigger")
.off('click')
.on('click', window.parent.showLogin);
$(".auction-action-autobid-input").attr('disabled', true);
}
function bindAutobidTrigger() {
"use strict";
var sNickLoggato = $("#NickLoggato").length > 0 ? $("#NickLoggato").val() : "";
if (sNickLoggato.length <= 0) {
return showLoginAutobid();
}
$('.auction-action-autobid-trigger').off('click').on('click', function (evt) {
var triggerElement = $(this);
rippleButton(triggerElement, evt);
var autobidInputElement = $(".auction-action-autobid-input").eq(isSmartphoneDevice() ? 1 : 0);
var inputAmount = parseInt(autobidInputElement.val(), 10);
var dataInputAmount = parseInt(autobidInputElement.data("amount"), 10);
var autobidAmount = !isNaN(dataInputAmount) ? dataInputAmount : inputAmount;
autobidInputElement.removeData("amount");
var autobidLoader = $(".auction-autobid-loader-container");
var switchContainer = $('.autobid-switch-container');
triggerElement.removeAttr("data-autobid-button");
var isNotValidAmount = isNaN(inputAmount) && isNaN(dataInputAmount);
if (isNotValidAmount) {
if (isSmartphoneDevice() && !$("[data-stage='2']").is(":visible"))
return $(".auction-action-autobid-mobile .auction-action-autobid-input").trigger("focus");
} else {
autobidLoader.removeClass("hidden");
switchContainer.hide();
}
cleanAutobidRequest();
window._autoController.setAutobid('create', autobidAmount, function () {
switchContainer.show();
if (true == isSwitchEnabled())
return;
$(".autobid-switch.js-switch:hidden")
.eq(isSmartphoneDevice() ? 1 : 0)
.data("autobid-enabled", "true")
.trigger('click');
$(".auction-autobid-button")
.toggleClass("active", true)
.find(".bi-autobid")
.toggleClass("bi-dark", false)
.toggleClass("bi-green", true);
var id_product = getUrlParam("a").split("_").reverse()[0];
$("#DA"+id_product).find('.favorite').attr('title', "Non puoi rimuoverla dai preferiti se è attiva l\'autopuntata");
$("#DA"+id_product).find('.favorite').attr('data-original-title', "Non puoi rimuoverla dai preferiti se è attiva l\'autopuntata");
$("#DA"+id_product).find('.favorite').attr('disabled', 'disabled');
$("#DA"+id_product).find('.favorite').addClass('active');
if (isDeepModal()) {
window.parent.BidooCnf.instances.auction.features.startAutobidAuctionUpdate();
}
if (isSmartphoneDevice())
return;
setTimeout(function () {
$('.auction-action-autobid-trigger').text(getTexts().autobid_add);
}, 400);
});
});
}
function setAutobid() {
"use strict";
if ('undefined' == typeof window['autobid_switchery']) {
return;
}
bindAutobidTrigger();
$('.auction-action-autobid-trigger').not("[data-autobid-button]").trigger('click');
}
function updateAutobid(value) {
"use strict";
var element = $(".auction-autobid-current-value");
var oldValue = parseInt(element.eq(0).text(), 10);
if (value != oldValue) {
element.toggle(value > 0);
element.text(value);
return true;
}
return false;
}
function cleanAutobidRequest() {
"use strict";
$(".auction-action-autobid-input").val('');
window._autoController.stopTicker();
}
function updateAutobidStatus(status, value) {
"use strict";
switch (status) {
case 'set':
case 'create':
{
if (0 == value) {
closeSwitch();
} else {
updateAutobid(value);
}
break;
}
case 'unset':
{
closeSwitch();
unsetAutobid(0);
break;
}
}
}
function closeSwitch() {
$('.js-switch.autobid-switch')
.eq(isSmartphoneDevice() ? 1 : 0)
.data("autobid-enabled", "false")
.trigger("click");
}
function setAutobidUI(isAutobid) {
"use strict"
$(".auction-action-autobid-mobile .autobid-switch-container").toggle(isAutobid);
$(".auction-action-bid-mobile .auction-autobid-button")
.toggleClass("active", isAutobid)
.find(".bi-autobid")
.toggleClass("bi-dark", !isAutobid)
.toggleClass("bi-green", isAutobid);
$(".js-switch.autobid-switch")
.data("autobid-enabled", "false")
.trigger('click');
}
function setCorrectPlaceholder(isFocused) {
"use strict"
this.attr("placeholder", isFocused ? "" : $(this).data("placeholder"));
$(".auction-action-autobid-mobile .auction-action-autobid-trigger").toggleClass("disable", this.val().length == 0);
}
$(document).ready(function () {
"use strict";
window.autobid_switchery = [];
window.autobid_seat_switchery = [];
$(".js-switch.autobid-switch").each(function (k, item) {
window.autobid_switchery.push(new Switchery(item, {size: 'small'}));
});
$(".js-switch.autobid-seat-switch").each(function (k, item) {
window.autobid_seat_switchery.push(new Switchery(item, {size: 'small'}));
});
$('.js-switch.autobid-seat-switch').off('change').on('change', function () {
var self = this;
var isEnabled = $(this).is(':checked');
if (isEnabled) {
window.stage.getUpdate(function (update) {
window._autoController.setAutobid('create', update.me.budget.total, function () {
$(".autobid-seat-status").text(getTexts().autobid_active);
});
});
} else {
window._autoController.setAutobid('delete', null, function () {
updateAutobid(0);
$(".autobid-seat-status").text(getTexts().autobid_not_active);
});
}
if (isSmartphoneDevice())
setAutobidUI(isEnabled);
return true;
});
$('.js-switch.autobid-switch').off('change').on('change', function () {
var switchAutobid = $(this);
if (isSmartphoneDevice() && $("[data-stage='2']").is(":visible"))
return true;
if (!switchAutobid.is(':checked') && ("false" == switchAutobid.data("autobid-enabled") || confirm(getTexts().dialog_confirm))) {
unsetAutobid({});
var id_product = getUrlParam("a").split("_").reverse()[0];
$("#DA"+id_product).find('.favorite').removeAttr('disabled');
$("#DA"+id_product).find('.favorite').attr('title', "Rimuovi quest\'asta dalle tue preferite");
if (isDeepModal()) {
$("#DA"+id_product).find('.favorite').removeAttr('data-original-title');
window.parent.BidooCnf.instances.auction.features.stopAutobidAuctionUpdate();
setTimeout(function () {
$("#divAsta"+id_product, parent.document).find('.favorite').removeAttr('data-original-title');
$("#divAsta"+id_product, parent.document).find('.favorite').removeAttr('disabled');
$("#divAsta"+id_product, parent.document).find('.favorite').attr('title', "Rimuovi quest\'asta dalle tue preferite");
}, 500);
} else {
$("#DA"+id_product).find('.favorite').attr("data-original-title", "Rimuovi quest\'asta dalle tue preferite");
}
}
return true;
});
$(".autobid-speed-dial > div > a").on('click', function (e) {
e.preventDefault();
var self = $(this);
var amount = parseInt(self.attr("data-amount"));
$(".auction-action-autobid-input").data("amount", amount);
$('.auction-action-autobid-trigger').attr("data-autobid-button", true).trigger('click');
});
var scopeElement = $(".auction-action-autobid-input[data-placeholder]");
scopeElement
.focus(setCorrectPlaceholder.bind(scopeElement, true))
.blur(setCorrectPlaceholder.bind(scopeElement, false));
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Some files were not shown because too many files have changed in this diff Show More