# BGG Collection Manager: riscrittura in SvelteKit

Data: 2026-08-03
Stato: approvato in sede di design, da trasformare in piano di implementazione

## 1. Obiettivo

Riscrivere l'applicazione attuale (PHP 7/8 + MySQL, ospitata su TopHost) mantenendo
tutte le funzioni esistenti, con tre aggiunte:

1. ogni utente può vedere i propri dati privati BGG, non solo PaoloAlby;
2. i job di sincronizzazione diventano processi veri invece di richieste web,
   eliminando il macchinario anti-timeout costruito per aggirare TopHost;
3. l'interfaccia passa da sito web a webapp, mobile first.

Vincolo assoluto: tutto deve restare su piani gratuiti.

## 2. Stato attuale, misurato

Rilevato il 2026-08-03 sul database di produzione:

| tabella | righe | dati | indici |
|---|---|---|---|
| games | 156.996 | 70,6 MB | 0,0 MB |
| games_categorie | 432.042 | 13,5 MB | 0,0 MB |
| games_meccaniche | 385.629 | 12,5 MB | 0,0 MB |
| games_autori | 167.316 | 5,5 MB | 0,0 MB |
| video | 34.315 | 3,5 MB | 0,0 MB |
| punteggi | 21.247 | 3,0 MB | 0,0 MB |
| autori | 39.587 | 2,5 MB | 0,0 MB |
| giochi | 3.955 | 2,5 MB | 0,0 MB |
| partite | 6.690 | 1,0 MB | 0,3 MB |
| utenti | 9 | trascurabile | 0,0 MB |

Totale circa 115 MB. La colonna degli indici è quasi ovunque a zero: oltre alle
chiavi primarie non esistono indici, quindi le pagine categoria, meccanica e
autore fanno scansioni complete su 157.000 righe.

I conteggi della tabella sono le stime di `information_schema`. I valori esatti
via `COUNT(*)`, rilevati lo stesso giorno, sono: 157.875 giochi in catalogo,
4.075 righe di collezione, 7.798 partite, 20.772 punteggi, 34.280 video,
9 utenti.

Dei 157.875 giochi in catalogo, 29.863 hanno `rank > 0`. Tutte le query di
navigazione del catalogo filtrano su `rank > 0`, ma il catalogo mondiale
completo è un obiettivo esplicito del progetto e va mantenuto.

## 3. Vincoli delle piattaforme, verificati il 2026-08-03

**Vercel Hobby.** I cron job possono girare al massimo una volta al giorno, con
precisione oraria di più o meno 59 minuti. Le funzioni hanno durata massima 300
secondi e memoria 2 GB. Ne segue che i job di sincronizzazione non possono
girare su Vercel senza reintrodurre chunk e concatenazioni di invocazioni.

**GitHub Actions.** Repository privato: 2.000 minuti al mese gratuiti.
Repository pubblico: minuti illimitati. Durata massima per job: 6 ore. Nessun
limite di frequenza dei cron. I segreti stanno nei GitHub Secrets, non nel
codice, quindi il repository può essere pubblico senza esporre nulla.

**Turso (piano gratuito).** 5 GB di storage, 100 database, 500 milioni di righe
lette al mese, 10 milioni di righe scritte al mese, 3 GB di sync. Il limite
vincolante per questo progetto è quello delle scritture.

**Dump dei rank BGG.** Verificato scaricandolo davvero, con login riuscito
(HTTP 204) e file `boardgames_ranks_2026-08-03.zip` da 3,8 MB compressi, 10,6 MB
di CSV. Contiene 179.619 righe, di cui 31.059 con rank e 148.560 senza. Colonne:

```
id, name, yearpublished, rank, bayesaverage, average, usersrated,
is_expansion, abstracts_rank, cgs_rank, childrensgames_rank,
familygames_rank, partygames_rank, strategygames_rank,
thematic_rank, wargames_rank
```

URL: `https://boardgamegeek.com/data_dumps/bg_ranks`, che redirige a un link S3
firmato. Richiede un account BGG loggato.

## 4. Architettura

```
Vercel (SvelteKit)                      GitHub Actions
- pagine, filtri, ricerca               - sync-catalog     (settimanale)
- form login BGG                        - sync-details     (notturno)
- sessione firmata via cookie           - sync-collections (notturno)
- solo letture dal DB,                  - sync-plays       (notturno)
  più le scritture dei dati privati     - sync-videos      (settimanale)
                                        - solo scritture
                    \                  /
                     Turso (SQLite via libSQL)
                     schema e accesso condivisi in TypeScript
```

Il confine è netto: il sito non scrive mai nel catalogo, i job non servono mai
una richiesta HTTP. L'unica scrittura che parte dal sito è l'aggiornamento dei
dati privati, che è per definizione un'azione dell'utente.

Repository unico contenente sito e job, con lo schema e le funzioni di accesso
al database condivisi. Il progetto attuale non è sotto controllo di versione:
il primo passo della migrazione è `git init` e la pubblicazione su GitHub, che è
anche la precondizione per far girare Actions.

Scelte tecniche:

- SvelteKit con `@sveltejs/adapter-vercel`
- Turso con client libSQL, schema in SQL scritto a mano in `migrations/` e query
  con parametri legati. Niente ORM: l'app ha una ventina di query note ed è quasi
  solo lettura, e un ORM sarebbe una dipendenza da mantenere per anni in cambio di
  poco. La garanzia che sostituisce il controllo statico è che i test eseguono le
  query vere contro lo schema vero, in memoria.
- Tailwind CSS al posto di Bootstrap
- `lucide-svelte` al posto di Font Awesome intero
- niente jQuery, niente DataTables, niente Morris.js

## 5. Modello dati

Nomi in inglese, coerenti in tutto lo schema. Sparisce la coppia
`games` / `giochi`, che oggi significa due cose diverse con lo stesso nome in
due lingue.

### 5.1 user

```sql
CREATE TABLE user (
  bgg_username        TEXT PRIMARY KEY,   -- sempre in minuscolo
  display_name        TEXT NOT NULL,
  owned_base          INTEGER NOT NULL DEFAULT 0,
  wishlist_base       INTEGER NOT NULL DEFAULT 0,
  owned_expansion     INTEGER NOT NULL DEFAULT 0,
  wishlist_expansion  INTEGER NOT NULL DEFAULT 0,
  to_play             INTEGER NOT NULL DEFAULT 0,
  plays_count         INTEGER NOT NULL DEFAULT 0,
  h_index             INTEGER NOT NULL DEFAULT 0,
  stats_updated_at    INTEGER,
  private_updated_at  INTEGER
);
```

La chiave primaria è sempre in minuscolo, normalizzata alla frontiera da
`normalizeUsername()`. La grafia originale vive solo in `display_name` e non
viene mai usata per confronti. Il bug storico `paoloalby` contro `PaoloAlby`, che
ha azzerato i dati privati per mesi, nasce da uno schema in cui due stringhe
possono essere la stessa cosa in MySQL e cose diverse in PHP. Il vecchio database
contiene tuttora la stessa persona come `HenryBoarder` nella tabella `utenti` e
`henryboarder` in `giochi`. Normalizzando alla scrittura la divergenza non è più
rappresentabile.

Le statistiche restano denormalizzate e vengono ricalcolate a fine sync.
L'h-index in SQL puro sarebbe una contorsione senza guadagno.

### 5.2 game

```sql
CREATE TABLE game (
  id                  INTEGER PRIMARY KEY,
  name                TEXT NOT NULL,
  alt_names           TEXT,
  year                INTEGER,
  is_expansion        INTEGER NOT NULL DEFAULT 0,

  -- volatili, dal dump settimanale
  rank                INTEGER,
  rank_abstract       INTEGER,
  rank_cgs            INTEGER,
  rank_children       INTEGER,
  rank_family         INTEGER,
  rank_party          INTEGER,
  rank_strategy       INTEGER,
  rank_thematic       INTEGER,
  rank_war            INTEGER,
  bayes_average       REAL,
  average             REAL,
  users_rated         INTEGER,
  ranks_updated_at    INTEGER,

  -- statici, dalla Thing API, una volta per gioco
  image               TEXT,
  thumbnail           TEXT,
  min_players         INTEGER,
  max_players         INTEGER,
  min_playtime        INTEGER,
  max_playtime        INTEGER,
  details_fetched_at  INTEGER
);

CREATE INDEX idx_game_rank        ON game(rank)        WHERE rank > 0;
CREATE INDEX idx_game_users_rated ON game(users_rated);
CREATE INDEX idx_game_pending     ON game(details_fetched_at) WHERE details_fetched_at IS NULL;
CREATE INDEX idx_game_name        ON game(name COLLATE NOCASE);
```

Le otto colonne di rank per categoria sono nuove: il dump le fornisce e oggi non
vengono usate. Abilitano pagine tipo "migliori giochi di strategia" senza costo
aggiuntivo di sincronizzazione.

`details_fetched_at IS NULL` è la coda di lavoro del job dei dettagli. Non
esiste una tabella di code e non esiste il valore sentinella `data_agg =
'2000-01-01'` usato oggi, che è un valore finto con un significato diverso da
quello promesso dal nome della colonna.

### 5.3 Tassonomie

```sql
CREATE TABLE category (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE mechanic (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE designer (id INTEGER PRIMARY KEY, name TEXT NOT NULL);

CREATE TABLE game_category (game_id INTEGER, category_id INTEGER, PRIMARY KEY (game_id, category_id));
CREATE TABLE game_mechanic (game_id INTEGER, mechanic_id INTEGER, PRIMARY KEY (game_id, mechanic_id));
CREATE TABLE game_designer (game_id INTEGER, designer_id INTEGER, PRIMARY KEY (game_id, designer_id));

CREATE INDEX idx_gc_category ON game_category(category_id);
CREATE INDEX idx_gm_mechanic ON game_mechanic(mechanic_id);
CREATE INDEX idx_gd_designer ON game_designer(designer_id);
```

Gli indici sulla seconda colonna delle tabelle ponte sono quelli che oggi
mancano e che causano le scansioni complete nelle pagine categoria, meccanica e
autore.

### 5.4 collection_item

```sql
CREATE TABLE collection_item (
  bgg_username        TEXT NOT NULL COLLATE NOCASE,
  game_id             INTEGER NOT NULL,
  name                TEXT NOT NULL,        -- nome dell'edizione posseduta
  image               TEXT,
  thumbnail           TEXT,
  owned               INTEGER NOT NULL DEFAULT 0,
  previously_owned    INTEGER NOT NULL DEFAULT 0,
  wishlist            INTEGER NOT NULL DEFAULT 0,
  wishlist_priority   INTEGER,
  for_trade           INTEGER NOT NULL DEFAULT 0,
  plays               INTEGER NOT NULL DEFAULT 0,
  rating              REAL,
  is_expansion        INTEGER NOT NULL DEFAULT 0,

  -- privati, popolati solo su azione dell'utente
  price_paid          REAL,
  current_value       REAL,
  acquired_date       TEXT,
  acquired_from       TEXT,
  private_comment     TEXT,

  updated_at          INTEGER NOT NULL,
  PRIMARY KEY (bgg_username, game_id)
);

CREATE INDEX idx_ci_user ON collection_item(bgg_username);
CREATE INDEX idx_ci_game ON collection_item(game_id);
```

### 5.5 play e play_score

```sql
CREATE TABLE play (
  bgg_play_id   INTEGER NOT NULL,
  bgg_username  TEXT NOT NULL COLLATE NOCASE,
  game_id       INTEGER,
  game_name     TEXT NOT NULL,   -- nome al momento della partita
  played_on     TEXT NOT NULL,
  duration      INTEGER,
  location      TEXT,
  PRIMARY KEY (bgg_play_id, bgg_username)
);

CREATE TABLE play_score (
  bgg_play_id   INTEGER NOT NULL,
  bgg_username  TEXT NOT NULL COLLATE NOCASE,
  player_name   TEXT NOT NULL,
  score         REAL,
  win           INTEGER NOT NULL DEFAULT 0,
  is_new        INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (bgg_play_id, bgg_username, player_name)
);

CREATE INDEX idx_play_user ON play(bgg_username);
CREATE INDEX idx_play_date ON play(played_on);
CREATE INDEX idx_play_game ON play(game_id);
```

`game_id` è nullable perché una partita può riferirsi a un gioco non ancora
presente in `game`. In quel caso `game_name` è l'unica fonte del nome. Questo
sostituisce il LEFT JOIN con COALESCE introdotto come correzione in
`partite.php`, rendendolo una proprietà dello schema invece di una toppa nella
query.

### 5.6 video

```sql
CREATE TABLE video (
  bgg_video_id  INTEGER PRIMARY KEY,
  game_id       INTEGER NOT NULL,
  url           TEXT NOT NULL,
  language      TEXT,
  author        TEXT
);

CREATE INDEX idx_video_game ON video(game_id);
```

### 5.7 login_attempt

```sql
CREATE TABLE login_attempt (
  attempt_key   TEXT NOT NULL,       -- username oppure indirizzo IP
  attempted_at  INTEGER NOT NULL
);

CREATE INDEX idx_login_attempt ON login_attempt(attempt_key, attempted_at);
```

Serve al limitatore di tentativi descritto in 7.3. Le righe più vecchie di
quindici minuti vengono cancellate a ogni controllo.

## 6. Nomi dei giochi

Il modello è a due livelli, verificato sui dati reali. I nomi personalizzati
esistono già e non sono soprannomi liberi: sono le edizioni localizzate
possedute da ciascuno. Esempi presi dalla collezione di DobleAce11:

| nome dell'utente | nome canonico |
|---|---|
| Alta Tensione | Power Grid |
| Progetto Gaia | Gaia Project |
| Tzolk'in: Il Calendario Maya | Tzolk'in: The Mayan Calendar |
| Fantascatti Special | Ghost Blitz: Spooky Doo |

Tutti questi nomi compaiono anche in `game.alt_names`, perché sono nomi
alternativi ufficiali registrati su BGG.

Regola di visualizzazione:

- dentro il contesto di una persona (la sua collezione, le sue partite, le sue
  statistiche) vince `collection_item.name`, con fallback su `game.name`;
- nel catalogo (scheda gioco, autore, categoria, meccanica, classifiche) vince
  `game.name`;
- nelle pagine condivise fra più utenti (collezione totale, elenco partite di
  gruppo) vince `game.name`, perché non esiste un contesto personale a cui
  appartenere;
- per una partita il cui gioco non è più in collezione, il fallback è
  `play.game_name`.

Implementazione: una sola funzione di display che riceve il gioco e l'eventuale
utente di contesto, usata da tutte le pagine, così la regola non può divergere.

La ricerca deve cercare su tutti e tre i campi: `game.name`, `game.alt_names` e
`collection_item.name`. Senza il terzo, cercare "Alta Tensione" nel catalogo
globale non troverebbe Power Grid.

## 7. Sincronizzazione

### 7.1 I cinque job

| job | frequenza | descrizione | scritture stimate |
|---|---|---|---|
| `sync-catalog` | settimanale, domenica notte | scarica il dump, upsert dei 179.619 giochi; i nuovi entrano con `details_fetched_at` a NULL | circa 180.000 a settimana, 772.000 al mese |
| `sync-details` | notturno | pesca dalla coda `details_fetched_at IS NULL`, Thing API a batch di 20, riempie campi statici e tassonomie | circa 1,8 milioni una tantum, poi vicino a zero |
| `sync-collections` | notturno | collection API per ogni utente, upsert dei soli delta, ricalcolo statistiche | qualche migliaio |
| `sync-plays` | notturno, più riconciliazione completa il primo del mese | pagine finché non incontra partite già note | qualche centinaio |
| `sync-videos` | settimanale | come oggi, sui giochi in collezione più i recenti con rank | qualche centinaio |

Totale a regime: ben sotto il 15% del limite di 10 milioni di scritture mensili
di Turso. Il mese del backfill iniziale resta comunque entro il limite.

Nota deliberata: **non** si implementa il confronto preventivo dei valori prima
della scrittura. Serviva solo nell'ipotesi di catalogo aggiornato ogni notte
(5,4 milioni di scritture al mese, il 54% del budget). A cadenza settimanale il
problema non esiste e l'upsert diretto è più semplice.

### 7.2 Note per singolo job

**sync-catalog.** Login su `https://boardgamegeek.com/login/api/v1` con le
credenziali di PaoloAlby prese dai GitHub Secrets, download dello zip, parsing
del CSV, upsert massivo in transazioni da 1.000 righe. Aggiorna solo le colonne
volatili e `ranks_updated_at`. Non tocca mai le colonne statiche.

**sync-details.** Ordinamento della coda per priorità: prima i giochi presenti
in `collection_item` o in `play`, poi quelli con `rank > 0` in ordine di rank,
poi tutti gli altri per `users_rated` decrescente. Batch di 20 ID sulla Thing
API, pausa di 2 secondi fra i batch, User-Agent da browser e header
`Authorization: Bearer`. Tetto di durata a 90 minuti per esecuzione, non perché
qualcuno uccida il processo ma per non tenere occupato un runner tutta la notte.
Quando la coda si svuota il job finisce in pochi secondi.

Il rilevamento `items_empty` di oggi non serve più: gli ID vengono dal dump,
quindi corrispondono sempre a giochi esistenti. Resta comunque un controllo
difensivo che marca `details_fetched_at` e prosegue, per non creare cicli
infiniti se BGG restituisce una risposta vuota.

**sync-collections.** Nessuna credenziale: la collection API senza
`showprivate=1` è pubblica. Per ogni utente scarica giochi base ed espansioni,
fa l'upsert dei soli record cambiati, marca come non più posseduti quelli
spariti, e inserisce in `game` gli eventuali giochi sconosciuti con i soli campi
disponibili e `details_fetched_at` a NULL. Non tocca mai le colonne private.

**sync-plays.** Nessuna credenziale. Scarica le pagine da 100 partite finché non
incontra una pagina interamente composta da `bgg_play_id` già presenti, poi si
ferma. Il primo giorno di ogni mese ignora questa condizione e riscarica tutto,
per intercettare partite modificate o cancellate a posteriori. Sostituisce il
DELETE totale seguito da reinserimento fatto oggi ogni notte, che da solo
costerebbe 857.000 scritture al mese.

**sync-videos.** Logica invariata rispetto a `cron/aggiorna_video.php`: due
chiamate JSON alle gallerie `instructional` e `review` più lo scraping della
pagina BGG per il meta tag `og:video`, con priorità all'italiano e fallback
all'inglese.

### 7.3 Concorrenza ed errori

Un solo run per workflow alla volta, tramite `concurrency` nel file YAML. Non
servono lockfile.

I fallimenti sono gestiti da Actions: log conservati, notifica via email,
possibilità di rilanciare un job dall'interfaccia. Non si costruisce nessuna
dashboard di monitoraggio.

## 8. Credenziali e sessione

### 8.1 Decisione

Le credenziali BGG degli utenti non vengono mai salvate, in nessuna forma, né in
chiaro né cifrate. Passano dal server solo per il tempo di una richiesta.

BGG non offre OAuth: l'unico modo di leggere i dati privati è username e
password che producono un cookie di sessione. Erano state valutate due
alternative e scartate: conservare il cookie di sessione cifrato (permetterebbe
l'aggiornamento automatico ma significherebbe custodire un credenziale capace di
agire sull'account altrui) e cifrare con una passphrase dell'utente
(crittograficamente solido ma inutilizzabile da un job, quindi equivalente alla
soluzione scelta con molto più codice).

### 8.2 Flusso

```
1. l'utente apre "I miei dati privati", non ha sessione, vede un form
2. inserisce username e password BGG, POST al server, solo HTTPS
3. il server chiama boardgamegeek.com/login/api/v1
   HTTP 204 significa identità dimostrata
4. con quel cookie chiama subito collection?username=X&stats=1&showprivate=1
5. scrive i campi privati su collection_item e aggiorna user.private_updated_at
6. scarta cookie e password: mai su disco, mai su database, mai nei log
7. emette la sessione applicativa: cookie firmato HMAC,
   HttpOnly, Secure, SameSite=Lax, 30 giorni
```

Il passo 6 è il cuore del design. Dopo il passo 5 non resta niente di
riutilizzabile: chi rubasse il database troverebbe i prezzi pagati, non un modo
per entrare negli account BGG.

Conseguenza diretta: non esiste tabella account, non esistono password nostre,
non esiste recupero password, non serve un servizio email, non serve un provider
OAuth, non esiste tabella sessioni, non serve cifratura né gestione di chiavi.

### 8.3 Le tre misure obbligatorie

**Limitazione dei tentativi.** Il form è di fatto un proxy verso il login di
BGG. Massimo cinque tentativi ogni quindici minuti per username e per indirizzo
IP, tramite la tabella `login_attempt`.

**Nessun logging del corpo della richiesta.** L'handler del login vive in un
file dedicato e minimale, leggibile in trenta secondi, così è verificabile da
chiunque chieda conto di cosa succede alla propria password.

**Verifica del proprietario.** Ogni pagina che mostra dati privati confronta
l'username della sessione con quello dei dati richiesti. Senza questo controllo
basterebbe cambiare un parametro nell'URL per leggere i prezzi altrui.

### 8.4 Visibilità

I dati privati sono visibili solo al proprietario, incluso rispetto al
proprietario del sito. Il sito resta pubblico per tutto il resto, con `noindex`
e `robots.txt` che escludono i motori di ricerca. I meta tag OpenGraph restano,
perché servono all'anteprima dei link nelle chat e sono indipendenti dal
`noindex`.

### 8.5 Aggiornamento dei dati privati

Avviene solo ripetendo il login. L'applicazione lo suggerisce quando
`user.private_updated_at` è più vecchio di tre mesi, mostrando il form nel punto
in cui il messaggio compare. Chi non fornisce mai le credenziali usa tutto il
resto del sito senza differenze e perde solo la pagina del valore della
collezione.

Per PaoloAlby valgono le stesse regole degli altri: le credenziali nei GitHub
Secrets servono solo a scaricare il dump del catalogo, non a leggere dati
privati in background.

## 9. Interfaccia

Il lavoro visivo è una fase separata, successiva alla chiusura di questa spec.
Qui si fissano solo le decisioni strutturali che condizionano il codice.

**Guscio applicativo.** Niente hero con immagine casuale, niente ricaricamento
completo fra le pagine. Navigazione persistente, contenuto che cambia.

**Mobile first.** Il caso d'uso principale è la scelta del gioco a tavolo con il
telefono in mano: i filtri per numero di giocatori e durata di `index.php` sono
la funzione più usata del sito e vanno progettati prima per schermo piccolo.

**Rimozioni.** Spariscono jQuery, DataTables, Bootstrap, Font Awesome intero,
Morris.js e Raphael. Filtri, ordinamenti e ricerca diventano stato di un
componente, il che elimina anche `ricerca.php` e `ricerca_tot.php`, che esistono
solo per alimentare le tabelle via AJAX.

**Sostituzioni.** Tailwind CSS, `lucide-svelte` per le sole icone usate. La
libreria per i grafici di `valutazione.php` e `punteggi_grafici.php` verrà scelta
nella fase di design.

**Immagini.** Le copertine continuano a essere servite dal CDN di BGG.

### 9.1 Consolidamento delle pagine

Da 36 file PHP a 18 route.

| route nuova | sostituisce |
|---|---|
| `/` collezione con filtri | `index.php`, `ricerca.php` |
| `/collezione/tutti` | `collezione_totale.php`, `collezione_pe.php`, `ricerca_tot.php` |
| `/partite` con filtri utente e anno | `partite.php`, `partite_all.php`, `partite_anno.php` |
| `/da-giocare` con filtri | `da_giocare.php`, `da_giocare_paolo.php` |
| `/valutazioni` con selettore lista/griglia | `rate_list.php`, `rate_grid.php` |
| `/categorie` | `categorie.php`, `categorie_story.php` |
| `/lista/:preset` (wishlist, da votare, in vendita) | `wishlist.php`, `rate_todo.php`, `in_vendita.php` |
| `/gioco/:id` | `gioco.php` |
| `/autore/:id`, `/categoria/:id`, `/meccanica/:id`, `/meccaniche` | invariate |
| `/giocatori`, `/giocatore/:nome` | `giocatori.php`, `giocatore.php` |
| `/valore-collezione` | `valutazione.php` |
| `/grafici-punteggi` | `punteggi_grafici.php` |
| `/utente/:nome` | `user.php` |
| `/io` (login BGG e dati privati) | nuova |

I filtri oggi cablati nelle query diventano parametri dell'interfaccia. Per
esempio le condizioni di `da_giocare_paolo.php` (mai giocato con un certo
avversario, durata superiore a 45 minuti, più di due giocatori) diventano filtri
applicabili da chiunque a qualunque utente.

Le tre liste `wishlist`, `rate_todo` e `in_vendita` sono la stessa vista di
`collection_item` con predicati diversi: condividono un componente e mantengono
URL distinti per le voci di menu.

**Eliminati senza sostituto:** `aggiorna.php`, `aggiorna_utente_wrapper.php`,
`aggiorna_utente_bg.php`, `aggiorna_utente_status.php`, i due wrapper in `cron/`,
`check_status.php`, `debug_sync.php`, `db_explorer.php`, `test_api.php`.

Le pagine autore, categoria e meccanica possono togliere il filtro `rank > 0`
che hanno oggi, perché il dump fornisce `average` e `users_rated` anche per i
giochi non classificati, quindi esiste un criterio di ordinamento sensato per
tutto il catalogo.

## 10. Migrazione

**Non c'è nessuna migrazione di dati.** Il database attuale è interamente una
cache di BGG: nessuna funzione dell'applicazione consente a un utente di
scriverci dentro, tutto arriva dalle API. Anche i dati privati di PaoloAlby
(`pricepaid`, `currvalue`, `acquisitiondate`, `acquiredfrom`, `privatecomment`)
risiedono su BGG e tornano giù al primo login sul sito nuovo, con il flusso
descritto nella sezione 8.

L'unica cosa che si perde temporaneamente sono i 34.280 video: sono
riscaricabili, ma il recupero passa dallo scraping delle pagine BGG e richiede
settimane. Nel frattempo ad alcuni giochi manca il collegamento al tutorial. È
un costo in tempo, non una perdita di dati.

L'unica cosa da portare avanti dal vecchio database è l'elenco di chi
sincronizzare, che è configurazione e non dati: nove utenti, che vivono in
`config/users.json` nel repository.

Rilevamento del 2026-08-03, a conferma: dei 4.075 record di collezione, solo 444
appartengono a PaoloAlby e solo circa 300 hanno un campo privato valorizzato.
Nessun altro utente ha mai avuto dati privati, non avendo mai potuto inserire le
proprie credenziali.

Ordine dei passi, con il sito attuale acceso e funzionante fino all'ultimo:

1. `git init`, repository su GitHub, scheletro SvelteKit, schema su Turso e
   seed dei nove utenti.
2. I cinque job su GitHub Actions. Da qui i dati sono vivi e aggiornati mentre
   il sito vecchio continua a girare.
3. Il sito SvelteKit, route per route, con i consolidamenti della sezione 9.1.
4. Login BGG e dati privati.
5. Fase di design dedicata.
6. Passaggio del dominio. Il sito vecchio resta spento ma disponibile come rete
   di sicurezza, database compreso.

## 11. Cosa sparisce rispetto a oggi

Non per averlo scritto meglio, ma perché il problema non si presenta più:

- wrapper anti-timeout, lockfile, esecuzione in chunk da 8 minuti, heartbeat
  (i job non sono più richieste web);
- riconnessione al database durante le esecuzioni lunghe;
- rilevamento `items_empty` e fast-skip degli ID cancellati (gli ID vengono dal
  dump, quindi esistono);
- inserimento di stub con `data_agg = '2000-01-01'` per i giochi nuovi
  (`details_fetched_at IS NULL` è la coda);
- dashboard di monitoraggio e file di stato JSON (i log sono di Actions);
- confronto case-insensitive esplicito degli username sparso nelle query
  (normalizzazione in minuscolo alla scrittura);
- credenziali e password in chiaro nei file di configurazione (GitHub Secrets e
  variabili d'ambiente Vercel);
- LEFT JOIN con COALESCE per le partite di giochi non in catalogo
  (`play.game_name` è una colonna).

## 12. Rischi e punti da verificare in implementazione

1. **Dimensione su Turso.** 115 MB in MySQL senza indici diventano una stima di
   250-300 MB in SQLite con gli indici previsti, contro 5 GB disponibili. Va
   misurato dopo la migrazione, non prima.
2. **Durata del backfill.** 179.619 giochi diviso 20 per batch sono circa 9.000
   chiamate; con 2 secondi di pausa sono circa 5 ore di traffico verso BGG.
   Spalmate su tre o quattro notti da 90 minuti. Da verificare che BGG non
   applichi limiti più stringenti su volumi di questo tipo.
3. **Stabilità del dump.** Il formato del CSV e l'URL potrebbero cambiare senza
   preavviso. Il job deve fallire in modo esplicito e rumoroso, non scrivere dati
   parziali.
4. **Durata del login BGG.** Il flusso dei dati privati dipende dal fatto che
   l'endpoint di login resti quello attuale. È già cambiato una volta di recente
   (l'aggiunta del Bearer token obbligatorio, e il rifiuto del POST sul
   sottodominio www).
5. **Ore di runner Actions.** Repository privato: 2.000 minuti al mese. Il mese
   del backfill è il più caro. Se stringe, il repository può diventare pubblico
   senza esporre segreti.
