# BGG Collection Manager

Riscrittura in SvelteKit dell'applicazione PHP che sta in `../bgg`.

**Online:** https://bgg-app-weld.vercel.app
**Repository:** `paoloalby/bgg-app` (privato)
**Utente BGG di riferimento:** PaoloAlby

> `../bgg` è il sito PHP ancora in produzione su duebytes.it. **Non va
> modificato**: resta acceso come rete di sicurezza finché non si sposta il
> dominio. Va letto solo come riferimento, ed è una fonte utile: molte scelte
> di questo progetto vengono da lì.

## Architettura

```
Vercel (SvelteKit)                      GitHub Actions
- pagine, filtri, ricerca               - sync-users     ogni notte 01:00 UTC
- letture, piu' i dati privati          - sync-details   ogni notte 02:00 UTC
  che scrive l'utente col suo login     - sync-catalog   domenica  03:00 UTC
                                        - sync-videos    ogni notte 04:00 UTC
                                        - sync-prices    lunedi'   05:00 UTC
                                        - sync-versions  martedi'  05:00 UTC
                                        - solo SCRITTURE
                    \                  /
                     Turso (SQLite via libSQL)
```

Il confine è quasi netto: **i job non servono mai una richiesta HTTP, e il
sito scrive solo le cinque colonne private, e solo quando è l'utente stesso a
fare il login BGG.** È quello che elimina i wrapper anti-timeout, i lockfile,
l'esecuzione a blocchi e gli heartbeat che il sistema vecchio doveva avere
perché i job erano richieste web su un hosting che le uccideva a 10 minuti.

Il database è **interamente una cache di BGG**: nessun utente può scriverci,
si può svuotare e ricostruire coi job. Fa eccezione il tempo.

## Comandi

```bash
npm run dev                                    # sviluppo, legge .env
npm run build                                  # compila
npm run check                                  # svelte-check, i tipi
npm test                                       # suite completa
npm test -- tests/nome.test.ts                 # un file solo
npx vercel --prod --yes                        # pubblica

npx tsx --env-file=.env scripts/apply-migrations.ts
npx tsx --env-file=.env scripts/seed-users.ts
npx tsx --env-file=.env scripts/cookie-bgg.ts | gh secret set BGG_COOKIE
npx tsx --env-file=.env scripts/export-partite.ts        # partite con durata, per Instagram
npx tsx --env-file=.env scripts/trova-duplicati.ts       # le copie create da BG Stats
npx tsx --env-file=.env scripts/confronta-bgstats.ts <file.bgsplay> <utente>
npx tsx --env-file=.env scripts/report-duplicati.ts <file.bgsplay> <utente> "<Nome>"

gh workflow run sync-users -f full_plays=true  # riconciliazione completa
gh workflow run sync-videos
gh run watch
turso db shell bgg "SELECT ..."
```

## Dove sta il codice

- `migrations/*.sql` e' la **fonte di verita' dello schema**: si aggiunge un
  file numerato, non si modifica un file gia' applicato.
- `src/lib/server/db/queries/*.ts` — una lettura sta sempre qui, mai dentro un
  `+page.server.ts`: e' quello che permette di provarla senza HTTP.
- `src/lib/server/bgg/*.ts` — tutto cio' che parla con BGG (client, collection,
  plays, thing, video, mercatino, login, il dump dei rank), piu' `xml.ts` che
  e' l'unico parser.
- `scripts/*.ts` — un file per job, piu' `registro.ts` (`apriDb()` che conta le
  scritture, `eseguiJob()` che scrive `job_run`). Il workflow `sync-users` ne
  incatena tre: `sync-collections`, `sync-plays`, `user-stats`.
- `config/users.json` — aggiungere una persona e' una riga e un commit.

## I test

Girano su SQLite **in memoria**: `createClient({ url: ':memory:' })` piu'
`applyMigrations(db)` in un `beforeEach`, poi le righe che servono a mano. Non
c'e' nessun database di prova su disco, e non deve essercene uno (vedi la
trappola del mount SMB). Ogni file di query ha il suo `tests/queries-*.test.ts`;
i parser hanno le loro fixture XML in `tests/fixtures/`.

## Regole che valgono ovunque

- **Gli username BGG sono sempre minuscoli come chiave.** Usare
  `normalizeUsername()` prima di qualunque confronto o scrittura. La grafia
  originale sta solo in `user.display_name`.
- **Le colonne private di `collection_item`** (`price_paid`, `current_value`,
  `acquired_date`, `acquired_from`, `private_comment`) le scrive solo l'utente
  facendo login sul sito. Nessun job pubblico le tocca mai: sono anni di storia
  d'acquisto che nessuna API ricostruisce.
- **Le foreign key sono attive**, in locale e su Turso. L'ordine di scrittura
  conta: tassonomie prima dei legami, partite prima dei punteggi.
- **Timestamp** interi Unix in secondi, **date di calendario** testo `YYYY-MM-DD`.
- **Nomi dei giochi:** dentro il mondo di una persona vince il nome della sua
  edizione (`collection_item.name`), nel catalogo vince quello canonico
  (`game.name`), e per le partite di giochi sconosciuti resta `play.game_name`.
- **Il sito usa `@libsql/client/web`**, non il client normale: quello trascina
  binding nativi che su una funzione serverless non servono. Gli script dei job
  usano il client normale.
- **Verso BGG:** sempre header `Authorization: Bearer` e uno **User-Agent
  onesto** (`bgg-collection-manager/1.0`), non un browser finto. Senza Bearer
  la risposta è 401; con un UA Chrome finto Cloudflare risponde 403. Non vale
  per `api.geekdo.com` (i video) né per il dump su S3: quelli usano
  `fetchWithRetry`, senza header BGG.
- **Un solo parser XML**, `bgg/xml.ts`. Configurarne uno a mano significa
  rifare il difetto delle entità numeriche in un terzo posto.
- **L'utente che si sta guardando** è `locals.utente`, messo da
  `hooks.server.ts` da cookie o da `?utente=`. Non si passa nei link.
  `locals.sessione` è invece chi ha fatto il login BGG, e vale solo per i
  dati privati.

## Trappole già pagate

- **BGG segnala alcuni errori con HTTP 200** e un corpo di errore. Un account
  cancellato dà 200 con `<errors>`. Preso per buono, azzererebbe una collezione.
- **BGG manda gli apostrofi come `&#039;`.** Il parser XML ha bisogno di
  `htmlEntities: true`, `processEntities` da solo non basta.
- **Un 429 non è un errore transitorio.** Attesa lunga (30s, 60s), non il
  backoff dei 5xx.
- **Il download del dump va su Amazon S3**, non su BGG: mandargli l'header
  Bearer lo fa rispondere 400.
- **Più giocatori con lo stesso nome** nella stessa partita sono normali
  ("Giocatore anonimo"). Niente chiavi o `{#each}` basati sul nome.
- **Il numero di righe di punteggio non è il numero di posti al tavolo.** Chi
  gioca in squadra su BGG ha due righe: una partita a Revive "in cinque" su un
  gioco da 1-4 erano Linda e Cinzia insieme. Il dato che distingue le due cose
  non esiste, né su BGG né qui, quindi non si può correggere: al massimo si
  segnala quando le righe superano il `maxPlayers`.
- **Contare i tavoli per data e numero di giocatori sembra equivalente a
  `senzaDoppioni` e non lo è.** Fonde le serate in cui si è rigiocato lo stesso
  gioco: quattro volte su Wingspan, sette su Terraforming Mars. È il motivo per
  cui i conteggi fatti a mano da fuori tornano più bassi dei nostri.
- **`/Users/paolo/Server` è un mount SMB**: un file SQLite lì perde scritture in
  silenzio. I database di prova vanno sotto la directory scratchpad di sessione.
- **GitHub Actions esegue quello che c'è su GitHub.** Cambiare il codice e
  lanciare un workflow senza aver pushato produce un verde che non significa
  niente.
- **Le entità numeriche erano in tre parser, ne era stato corretto uno.**
  Un difetto di configurazione replicato non si corregge dove si è visto:
  si toglie il punto in cui era replicabile.
- **`{#if}` a inizio riga in Svelte mangia lo spazio prima.** `{testo}{#if
  x}· altro{/if}` esce "testo· altro". Il separatore va costruito in
  JavaScript, o l'`{#if}` va su una riga sua.
- **Un'altezza in percentuale vuole un genitore con altezza definita.** Una
  barra dentro un flex senza `h-full` viene alta zero, e il grafico esce
  vuoto senza errori.
- **Da `$lib/server/...` un componente può importare solo `type`.** Importarne
  una costante fa fallire il build con "An impossible situation occurred", e
  in sviluppo la pagina resta bianca. Quello che serve a entrambi i lati sta
  in `$lib/` e basta (vedi `src/lib/catalogo.ts`).
- **Le chiavi di `{#each}` non si fanno con i nomi.** Due giochi si chiamano
  Puerto Rico, e la pagina del giocatore è rimasta vuota, menu compreso,
  finché la chiave non è diventata l'id.
- **Un `flex-col` allunga i figli fino all'altezza della colonna più alta.**
  Senza `items-start` attorno a un'immagine resta un riquadro vuoto più alto
  della figura.
- **Il deploy su Vercel vuole i commit di `paoloalby@hotmail.com`.** E'
  l'account con cui il progetto e' collegato: con un'altra identita' il push
  non fa partire il deploy. `git config user.email` in questo repository.
- **Quando GitHub ha disservizi il deploy automatico non parte**, e non e' una
  configurazione rotta: si pubblica a mano con `npx vercel --prod --yes`.
  Sintomo: i push arrivano su GitHub ma l'ultimo deploy resta vecchio di ore.
- **`strftime('%s', ...)` restituisce TESTO**, e in SQLite il testo si ordina
  dopo qualunque numero. Un `ORDER BY COALESCE(strftime(...), colonna_intera)`
  mette in cima tutte le righe con la data, comprese quelle di tre anni fa.
  Serve `CAST(... AS INTEGER)`.
- **La prima sincronizzazione di una persona non e' un elenco di arrivi.**
  Scrivere "visto oggi" su tutta la collezione fa sembrare che abbia comprato
  duecento giochi in una notte: alla prima passata quella data non si segna.
- **Un `$effect` che risincronizza un campo di testo con i dati della pagina
  combatte con chi digita.** Con la ricerca in ritardo, la risposta vecchia
  riscrive nel campo sopra le lettere nuove: "knizia" diventa "roseknizi".
  Il valore iniziale si legge una volta sola, con `untrack`.
- **La collection API va chiesta con `excludesubtype=boardgameexpansion`.**
  Senza, la chiamata "base" riporta anche le espansioni marcate come giochi
  base, e la seconda chiamata le riporta di nuovo come espansioni: ogni
  espansione veniva scritta due volte a notte, prima con `is_expansion` 0 e poi
  con 1. Il valore finale era giusto, quindi guardando i dati non si vedeva
  niente: si vedeva solo il conto delle scritture (~1.390 righe a notte, ora
  50).
- **Cloudflare blocca lo User-Agent finto di Chrome, non i runner GitHub.**
  Dal 12 settembre 2026 la collection API rispondeva 403 per tutti e dieci gli
  utenti, e la stessa chiamata falliva anche dal Mac: la pagina "Sorry, you
  have been blocked", identica con credenziali inventate. Un UA che dice
  Chrome senza gli header `sec-ch-ua` del Chrome vero e' un bot dichiarato;
  con `bgg-collection-manager/1.0` passa tutto, login compreso. Era la stessa
  causa del 403 sulla POST di login dai runner, attribuito a torto agli
  indirizzi di GitHub. Il cookie del secret `BGG_COOKIE` per `sync-catalog`
  resta: si rigenera con `scripts/cookie-bgg.ts` quando il job si lamenta del
  dump.
- **Un test verde che asserisce l'intenzione sbagliata protegge il difetto.**
  Quello della collection diceva "chiede i giochi base **senza** subtype
  espansione" ed era esattamente il bug. Quando si cambia una regola, il test
  vecchio va riscritto col perche' dentro, non adattato al nuovo risultato.
- **`excludesubtype=boardgameexpansion` contiene la stringa
  `subtype=boardgameexpansion`.** Un `toContain` senza la e commerciale davanti
  passa anche quando l'URL e' quello sbagliato.

## BG Stats duplica invece di aggiornare, e i conteggi ne risentono

Quasi tutti registrano le partite con BG Stats, che le sincronizza su BGG.
Quando si modifica una partita **gia' sincronizzata**, a volte l'app invece di
aggiornarla ne crea una copia: su BGG restano la vecchia e la nuova, nell'app
una sola. Visto in tre forme, tutte confermate sui dati veri:

- cambiando la **durata**: l'Ankh di Valentina c'e' due volte, 1283 minuti e 170;
- cambiando il **luogo**: e' il caso piu' frequente, e sono le copie di
  Valentina e il Railroad Ink di Paolo;
- **migrando le partite a un'altra edizione**: una resta sulla vecchia. Cosi'
  Paolo aveva la stessa partita su Village e su Village Big Box.

**Il nostro filtro dei doppioni non li prende, per scelta.** `senzaDoppioni()`
toglie la stessa serata registrata da **persone diverse**; due registrazioni
dello stesso account restano entrambe, perche' si puo' davvero rigiocare lo
stesso gioco due volte in un giorno. Quindi le copie contano nelle statistiche
finche' non vengono cancellate su BGG. A settembre 2026 si e' deciso di non
mettere nessun filtro: i dati restano lo specchio di BGG.

Per trovarle ci sono due script. `trova-duplicati.ts` guarda solo il nostro
archivio; `confronta-bgstats.ts` e `report-duplicati.ts` incrociano un export
`.bgsplay` con le nostre partite, e sono molto piu' precisi perche' l'app e' la
fonte giusta.

Quattro cose imparate facendoli, tutte pagate:

- **Il campo della data cambia da export a export.** Quello di Paolo porta
  `playDateYmd` (20260901), quello di Valentina il solo `playDate`
  ("2026-08-29 22:22:41"). Leggendone uno solo il confronto non aggancia niente
  e da' 1.712 falsi positivi su 2.400 partite.
- **Il file `.bgsplay` non contiene l'id BGG della partita.** Il `uuid` e'
  interno all'app: il collegamento si fa per gioco e giorno.
- **Luogo e durata insieme non bastano ad abbinare le righe.** Se si rinomina
  un luogo, su BGG resta il nome vecchio su tutte e due le copie e allora
  distingue la durata; se si corregge la durata, restano due durate vecchie
  uguali e distingue il luogo. Si abbina a punteggio e si prende il meglio.
- **Il numero da cancellare deve essere la differenza fra i due conteggi.** Se
  l'abbinamento ne lascia fuori di piu', le eccedenti tornano fra quelle da
  tenere: dire a qualcuno di cancellare una partita vera e' molto peggio che
  lasciargliene una doppia. Senza questa regola il conto dava 311 copie invece
  delle 169 reali.

**Le correzioni arrivano solo con la riconciliazione completa.** La
sincronizzazione notturna delle partite si ferma alla prima pagina interamente
nota, e le partite arrivano ordinate per data della partita, non di modifica:
una correzione su una partita vecchia non la vede. Il primo di ogni mese
`sync-plays` rilegge tutto, e si puo' forzare con
`gh workflow run sync-users -f full_plays=true`.

## Documentazione

`docs/superpowers/specs/` tiene la spec di design, che descrive il perche'
delle scelte di fondo e resta valida.

I cinque piani di costruzione e la lista di riscontri del 4 agosto sono stati
eseguiti fino in fondo e tolti da qui: erano ottomila righe di istruzioni per
un lavoro finito, e chi legge oggi ha bisogno del risultato, non delle
istruzioni. Restano in git, se mai servissero:

```bash
git log --diff-filter=D --name-only -- docs/superpowers/plans docs/feedback-*
git show <commit>^:docs/superpowers/plans/<file>
```

## Le pagine

Collezione, desideri, da giocare, classifica (schede o sole copertine), da
votare, partite (proprie o di tutta la community), ultimi 365 giorni,
giocatori, dettaglio giocatore, scaffali del gruppo, in vendita, spese e
sconti (dietro login BGG), categorie, meccaniche, autori, scheda gioco,
grafici dei punteggi, impostazioni, stato degli aggiornamenti.

Piu' tre endpoint che non sono pagine, in JSON, per chi legge i nostri dati
da fuori (le due sessioni di Claude Code: il generatore di post Instagram e
quella di Valentina):

- **`/api/gioco/{bggId}`** — la scheda. Dentro c'e' `partiteStats`, cioe'
  quanto dura il gioco al nostro tavolo diviso per numero di giocatori, e i
  tre conteggi delle partite. Sono tre e non uno perche' rispondono a tre
  domande diverse: `partite` sono le righe registrate, `partiteTavoli` quante
  volte il gruppo si e' seduto a giocarlo (`senzaDoppioni`), `partiteConDurata`
  su quante sono costruite le medie. Il numero da scrivere in una frase e'
  sempre il secondo, e `partiteStats.scartate` dice perche' il terzo e' piu'
  basso. Con `?utente=` arriva anche il blocco `utente`, cioe' la stessa
  scheda vista da una persona sola: se ce l'ha e se ci ha giocato. Sono le
  partite registrate da lei, non chi era al tavolo: i nomi dei giocatori sono
  testo libero e cercarli vorrebbe dire indovinare.
- **`/api/partite/{utente}`** — le partite di una persona, `?anno=` e
  `?gioco=`. Qui l'esclusione della vista d'insieme non si applica: toglie
  qualcuno dall'elenco di tutti, non dalle sue stesse partite.
- **`/api/collezione/{utente}`** — i giochi posseduti, `?espansioni=1`. Con
  `?giocati=1` arrivano anche quelli che quella persona ha giocato senza
  averli (`inCollezione: false`): sono 290 per Paolo, ed e' il caso di SETI,
  quattordici partite fra noi e nessuno che ce l'abbia.

Sono pubblici e senza autenticazione, quindi **niente dati privati**:
`inCollezione` dice si' o no e i cinque campi d'acquisto non passano di li'.
Un username sconosciuto da' 404 e non un elenco vuoto (`utenteEsiste()`), che
e' la risposta piu' difficile da capire quando hai solo sbagliato a scrivere
il nome.

Le specifiche per chi li usa stanno in `~/Desktop/api-portale-bgg.md`: quando
un endpoint cambia, va aggiornato quel file.

## Prestazioni: quello che abbiamo imparato misurando

**La funzione gira a Dublino, dove sta il database** (`regions: ['dub1']`
nell'adapter, in `vite.config.ts`). Senza quella riga Vercel usa la sua
regione predefinita, iad1, cioe' Washington, mentre Turso e' su
aws-eu-west-1: ogni query attraversava l'Atlantico e tornava. Misurato a
database sveglio, /meccaniche e' passata da 0,99 a 0,21 secondi e /stato da
0,64 a 0,19. Il sintomo da riconoscere e' l'header `x-vercel-id`: se dice
`fra1::iad1` la funzione sta in America.

**La prima richiesta dopo una pausa costa 1,7 secondi di Turso**, misurati
dentro la funzione con `/api/sveglia`, che fa un `SELECT 1` e restituisce
quanto ci ha messo: 1.685 ms dopo otto minuti di silenzio, 40 ms alla
chiamata successiva. Non e' la query, ed e' un fattore quaranta.

**Non dipende dalla dimensione del nostro archivio.** Provato con un secondo
database vuoto nella stessa regione, lasciando fermi entrambi e misurandoli a
turno: 1,45-1,53 secondi il vuoto contro 1,76-1,80 i nostri 138 MB. Trecento
millisecondi di differenza, non sei secondi. Quindi il catalogo BGG, che
occupa meta' del file, non c'entra, e sfoltirlo non servirebbe a questo.

Turso oggi dichiara che i suoi database non dormono e non hanno cold start,
perche' sono file e non processi. Sulla nostra istanza, che sta ancora
sull'infrastruttura `aws-eu-west-1.turso.io`, la misura dice il contrario: la
documentazione del fornitore non sostituisce il cronometro.

**Un numero misurato una volta sola non e' un numero.** Il primo giro aveva
dato 8,2 secondi ed era finito qui dentro come se fosse un fatto: ripetendolo
tre volte non si e' mai riprodotto. Prima di scrivere una misura in questo
file va rifatta.

**A freddo ogni pagina paga il suo avvio, a caldo il sito e' reattivo.**
Misurato dopo nove minuti di silenzio, navigando una pagina dopo l'altra:
/partite e /giocatori 3,0 secondi, /classifica 5,2, mentre / e /meccaniche
restavano sotto il mezzo secondo. Rifatte subito dopo, tutte fra 0,22 e 0,33.
Ogni route e' una funzione serverless a se', quindi il conto si paga una
volta per pagina e non una volta per visita: di quei secondi 1,7 sono Turso e
il resto e' la funzione che si avvia.

**Il precaricamento e' `tap`, non `hover`** (`app.html`). Il default di
SvelteKit e' pensato per pagine con pochi link: qui, scorrendo una tabella di
mille giochi, partiva una richiesta per ogni riga toccata dal mouse.

**I conteggi delle tassonomie sono colonne**, non un GROUP BY a ogni visita:
`games_count` su category, mechanic e designer, scritta da
`scripts/conta-tassonomie.ts` in coda a sync-catalog e sync-details.
`/meccaniche` ci metteva 11 secondi, ora 0,05.

**Una colonna larga dentro un GROUP BY costa piu' del conteggio, e questo
difetto si ripete.** Trovato due volte: prima sulle icone delle tassonomie,
poi sugli scaffali del gruppo, dove il LEFT JOIN sul catalogo stava prima del
GROUP BY e trascinava `alt_names` (i nomi del gioco in ogni lingua) e la
copertina nel B-tree temporaneo per tutte le righe della collezione. Da 56,65
secondi a 0,78, con le stesse 1.763 righe. La forma giusta e' sempre la
stessa: si raggruppa sulla tabella stretta in una sottoquery, e le colonne
larghe si agganciano dopo, una volta per riga finale. L'URL
dell'icona veniva trascinato nel B-tree temporaneo una volta per riga della
tabella ponte: sei secondi da solo. Si conta in una sottoquery e i nomi si
attaccano dopo.

**Le griglie di copertine si disegnano a scaglioni quanto le tabelle.**
`TabellaGiochi` ce li aveva, la griglia degli scaffali del gruppo no: 1.763
card tutte insieme facevano una pagina da 7 MB, di cui 6 erano markup
ripetuto e non dati. Portata a 150 per volta pesa 1,5 MB. Quando una pagina
e' pesante, prima di guardare le query conviene dividere il peso fra HTML
disegnato e blocco dati: qui i dati erano 879 KB, cioe' un ottavo.

**Le tabelle lunghe si disegnano a scaglioni.** `/categoria/1002` (9.836
giochi) pesava 8,3 MB: meta' URL di copertine, meta' classi CSS ripetute su
ogni cella. Ora 150 righe alla volta, 1 MB, e la ricerca risponde in 8 ms
invece di ridisegnare novemila righe a ogni tasto. I dati arrivano comunque
tutti: e' per questo che la ricerca resta immediata.

**Un indice che sembra ovvio puo' non servire a niente.** Provato
`game(id, is_expansion)`: SQLite continua a usare il rowid, piano e tempi
identici. Rimosso. `EXPLAIN QUERY PLAN` prima di aggiungerne uno.

**E uno che non sembrava ovvio serviva moltissimo.** La pagina dei giocatori
raggruppava i punteggi per nome senza indice: `SCAN play_score` su 31.710
righe piu' un B-tree temporaneo per il GROUP BY, 7,4 secondi a pagine fredde.
`idx_ps_user_player(bgg_username, player_name)` copre WHERE e GROUP BY
insieme e toglie anche il temporaneo: 0,19 secondi. La pagina intera e'
passata da 14,6 secondi a freddo a 0,29 a caldo.

**Il censimento di tutte le pagine, che va rifatto ogni tanto.** Un giro di
`curl` su ogni rotta con `%{time_starttransfer}`, `%{time_total}` e
`%{size_download}`, scegliendo per le pagine con parametro il caso peggiore e
non uno comodo: la categoria con piu' giochi, l'autore con piu' titoli, il
gioco con piu' partite. A settembre 2026 il giro dava tutto sotto 0,43
secondi tranne `/categoria/1002` e `/meccanica/2072`, che sono le uniche tre
voci del sito sopra i cinquemila giochi e stanno intorno al secondo. Trovare
le pagine lente da soli e' molto meglio che sentirselo dire.

**Il metodo, quando qualcosa e' lento:** prima `curl` con `%{time_total}` e
`%{size_download}`, poi la stessa query in uno script isolato. Se i due tempi
non combaciano, la differenza e' la pista. Il numero misurato va nel messaggio
del commit: "da 11 secondi a 0,05" vale piu' di "ottimizzata la query".

## Dati che non vengono da BGG

`data/` contiene le traduzioni italiane di meccaniche e categorie, le loro
icone e i codici Vinted, esportati dal vecchio database prima che venisse
spento. Le traduzioni le ha scritte Paolo a mano negli anni e BGG non le ha:
se si perdono quei file, si perdono. Si applicano con
`npx tsx --env-file=.env scripts/seed-traduzioni.ts`.

## Il menu

Quattro gruppi in `src/lib/menu.ts`, quattro domande: cosa ho, cosa abbiamo
giocato, cosa hanno gli altri, cos'altro esiste. Il titolo del gruppo non è un
link ma un contenitore, così si comporta uguale col mouse e col dito. Sotto i
768 pixel diventa un pannello unico con le intestazioni.

## I colori e i tre temi

**Nessuna pagina nomina un colore.** I nomi sono ruoli (`fondo`, `barra`,
`testata`, `campo`, `bordo`, `testo`, `tenue`, `superficie`,
`superficie-alt`, `superficie-hover`, `inchiostro`, `inchiostro-tenue`,
`accento`, `accento-scuro`, `accento-velo`, `accento-testo`, `su-accento`,
`oro`, `rosso`) e i valori arrivano dal tema, in `src/routes/layout.css`.
Un tema nuovo è un blocco di variabili, non una caccia a `zinc-900` in trenta
file.

L'accento è campionato dal meeple del logo (`#42b649`, tinta 124). L'emerald
di Tailwind ha tinta 160 e tira al ciano: accanto al logo si vedeva che erano
due verdi diversi.

Tre temi: **Feltro** (predefinito), Notte, Giorno. La scelta sta nel cookie
`bgg_tema`, la scrive la modale delle impostazioni dal browser, e l'hook la
mette su `<html data-tema>` prima di servire la pagina, così chi ha scelto
Giorno non vede il lampo scuro.

Due trappole di questo impianto, entrambe pagate:
- **`accento` come testo sul fondo non funziona in tutti i temi.** Il verde
  del meeple su crema fa 2,36:1. Per il verde-testo c'è `accento-testo`, che
  è chiaro sui temi scuri e scuro su Giorno.
- **Dentro la barra i token cambiano significato.** Nel tema Giorno la barra
  resta scura, quindi `.su-barra` ridefinisce testo, tenue, fondo, testata,
  bordo e campo. Un `hover:bg-fondo` dentro la barra, senza questo, prendeva
  il crema della pagina e la voce spariva.

## Le tendine

`src/lib/Tendina.svelte`, non `<select>`. La tendina di una select la disegna
il sistema operativo: `appearance: base-select` la consegna al CSS, ma solo su
Chromium, e su Firefox si vedeva quella di macOS dentro un sito scuro. Il
componente rifà a mano quello che la select dava gratis: frecce, Home/End,
Invio, Escape, chiusura cliccando fuori, salto digitando, ruoli ARIA con
`aria-activedescendant`.

## Link condivisibili

L'utente guardato e i filtri stanno nella URL, perché un elenco filtrato è
qualcosa che si manda a un amico. Il testo digitato no: quello è un modo di
cercare adesso, non di guardare la collezione.

**`?utente=` ha due significati e vanno tenuti distinti.** Con `ricorda=1` è
una scelta e finisce nel cookie per un anno (lo aggiunge solo la tendina).
Senza, è un link ricevuto: vale per la lettura e il cookie non si tocca,
altrimenti un link condiviso cancellerebbe a chi lo apre la propria
impostazione. La navigazione dentro un link condiviso se lo trascina dietro
via `beforeNavigate`, così vale per tutto il giro e non per una pagina sola.

## La quota di Turso, che e' l'unica risorsa che ci sta stretta

Delle tre (letture, scritture, spazio) solo le **righe scritte** contano: 10
milioni al mese sul piano gratuito, contro 500 milioni di letture e 5 GB di
spazio che non sfioriamo. Se un job puo' leggere di piu' per scrivere di meno,
lo fa: e' sempre il verso giusto.

**Ogni job conta quanto scrive**, sommando il `rowsAffected` di ogni risposta
(`apriDb()` in `scripts/registro.ts`), e il numero finisce in `job_run` e in
`/stato`. Serve perche' il grafico di Turso dice il totale del giorno senza
dire di chi e': la prima volta ho stimato a naso da dove venissero le
scritture e ho sbagliato di un ordine di grandezza, ottimizzando due job che
non c'entravano.

**Turso conta circa il doppio di quello che contiamo noi**, perche' insieme
alla riga scrive le voci degli indici. Misurato su giornate pulite: 1.412
dichiarate dai job, 2.828 contate da Turso. Per "quanto manca al limite" vale
il numero di Turso; il registro serve a sapere **quale** job consuma.

**La guardia controlla anche la quota** dalle API di Turso, e fallisce sopra
l'85%. I limiti non sono scritti nel codice: l'API dice il piano corrente e le
sue quote, quindi cambiando piano il controllo si adegua da solo. Serve il
secret `TURSO_API_TOKEN`, che e' un token dell'account e non del database.
Insieme alla percentuale arriva quanto manca all'azzeramento, perche' e' quello
a darle senso: il 96% con una settimana davanti e' un guaio, a ventidue ore dal
reset no.

**Attenzione a rimettere in coda i giochi.** La coda di `sync-details` non e'
filtrata per rilevanza (quel filtro vale solo per i legami): azzerare
`details_fetched_at` "per i nostri giochi" ha rimesso in coda tutti i 180.000
del catalogo e bruciato 345.000 righe fra il guasto e la riparazione. Prima di
un UPDATE di massa, contare quante righe tocca.

**La regola, ovunque si scriva: non riscrivere una riga identica.** In SQLite
un `UPDATE` che rimette lo stesso valore e' una scrittura come le altre.
- `ON CONFLICT ... DO UPDATE SET ... WHERE tabella.campo IS NOT excluded.campo`
  (`IS NOT`, non `!=`: con NULL il confronto normale non e' ne' vero ne' falso)
- legami e punteggi si leggono prima e si riscrivono solo se l'insieme e' cambiato
- il ricalcolo dei conteggi tocca solo le righe il cui totale e' cambiato

Misurato: sync-details e' passato da 59.411 scritture per 560 giochi a 4.352
per 640, e una sincronizzazione delle partite senza novita' da qualche
migliaio a zero. I test lo tengono fermo con `total_changes()`.

## Come sappiamo che i job girano

`job_run`: una riga per esecuzione, con esito e **quante cose ha toccato**.
Il numero è la parte importante: è l'unico modo di vedere il guasto che
nessun log intercetta, cioè il job che gira, esce verde e non aggiorna
niente. Ogni job passa da `eseguiJob()` in `scripts/registro.ts` e restituisce
il suo conteggio.

`/stato` mostra il registro. Il workflow `guardia` lo rilegge ogni mattina
alle 8 e **fallisce** se qualcosa non torna, così GitHub manda la mail: un job
che non parte più non può lamentarsi di non essere partito.

## I prezzi: due fonti, e la vostra vince

Il campo `current_value` di BGG lo scrive la persona, ed e' il numero
migliore. Dove manca c'e' un ripiego: `game.market_price`, la mediana delle
inserzioni **nuove e in euro** del mercatino BGG, scritta dal job settimanale
`sync-prices` per i soli giochi che qualcuno di noi possiede.

Tre filtri, ognuno per un motivo: le **usate** dicono quanto vale quella copia
e non quel gioco; le **altre valute** vorrebbero dire inseguire un cambio; e
sotto le **tre inserzioni** la mediana e' il prezzo di un venditore solo (su un
fuori produzione sono centocinquanta euro).

La stima riempie i buchi e si vede che e' una stima: in tabella e' in corsivo
e piu' tenue. Il prezzo sta sul catalogo perche' e' una proprieta' del gioco,
uguale per tutti; i cinque campi privati restano di chi li scrive.

## I dati privati e chi li puo' vedere

Si scaricano da BGG solo col proprio login, ma **una volta qui dentro sono
dati del gruppo**: la pagina delle spese segue l'utente scelto in alto, come
ogni altra pagina. Decisione presa insieme.

Il login pero' resta la porta: il portale sta su internet e chiunque puo'
scegliere un utente dalla tendina, quindi senza sessione non si vedono le
spese di nessuno. Chi entra vede quelle di tutti, e i tasti Aggiorna ed Esci
valgono sui propri dati.

**Il cookie di sessione BGG si conserva cifrato** (`user.bgg_cookie`,
AES-256-GCM con la stessa segreta che firma le sessioni) proprio per non
richiedere la password a ogni aggiornamento. Non e' la password: vale finche'
BGG lo tiene valido e si annulla cambiando password su BGG. Uscire lo
cancella.

La cautela che conta piu' di tutte: **se BGG risponde con una collezione
vuota non si scrive niente.** Li' una collezione vuota non e' una collezione
vuota, e' un cookie scaduto a cui BGG risponde senza dati privati: salvarla
cancellerebbe anni di prezzi.

## La regola dell'italiano sui video

Ripresa dal vecchio `aggiorna_video.php`: se per un gioco esiste anche un solo
video italiano si tengono soltanto quelli, l'inglese entra solo quando
l'italiano non c'è. La scelta vale sul gioco intero, non sulla singola
galleria.

Il punto delicato è il tempo: i tutorial italiani escono mesi dopo. Chi non ha
l'italiano torna in coda dopo 90 giorni (`RICONTROLLO_GIORNI`), e siccome i
video di un gioco si riscrivono per intero, l'inglese sparisce da solo il
giorno che arriva l'italiano. Chi ce l'ha già non si ricontrolla.

## I legami fra giochi, e i video che stanno sotto un altro id

I tutorial vengono girati per la prima edizione di un gioco e quasi mai
rifatti per quelle dopo. Chi apre La Granja: Deluxe Master Set non trova
niente, e il tutorial italiano è sotto La Granja del 2014. Nella direzione
opposta, Tuscany riscrive metà delle regole di Viticulture, e chi ha imparato
dal tutorial del gioco base arriva al tavolo preparato a metà.

La Thing API dichiara i legami, e `inbound` dice da che parte vanno:

| kind | inbound | significato |
|---|---|---|
| `expansion` | 0 | l'altro è un'espansione di questo |
| `expansion` | 1 | questo è un'espansione dell'altro |
| `implementation` | 1 | l'altro è l'edizione da cui deriviamo |
| `compilation` | 1 | l'altro è raccolto dentro questo |

**I tipi sono tre, non due.** The Castles of Burgundy del 2019 non
*reimplementa* quello del 2011: lo *compila*, insieme a sei espansioni.
Leggendo solo `implementation` le due edizioni sembravano estranee. Sui
`compilation` teniamo solo il gioco base (`is_expansion = 0`), altrimenti sotto
quella scheda comparirebbero sette blocchi di video.

Sulla scheda i blocchi restano separati, mai mescolati ai video del gioco: se
il tutorial giusto c'è già sopra si smette di scorrere, e se non c'è si sa da
dove arriva quello che si sta guardando. Su un'espansione il blocco "edizione
originale" non si mostra — chi apre Tuscany Essential Edition vuole le regole
di Viticulture, non la storia dell'espansione. Le espansioni mostrate sono solo
quelle possedute da qualcuno del gruppo: Viticulture ne dichiara undici, per lo
più mazzetti promozionali senza nessun tutorial.

I legami si chiedono solo per i giochi che qualcuno ha o ha giocato
(`links_fetched_at`, coda dentro `sync-details`). Sono ~2.800 giochi e ~14.500
legami; rifare la Thing API su tutto il catalogo per sapere le espansioni di un
titolo che nessuno possiede sarebbe una settimana di chiamate buttata. La coda
dei video prende anche i giochi con `inbound = 1` dei nostri: sono ~500 giochi
in più che nessuno possiede, ed è lì che stanno i tutorial che servono.

## L'iniziale degli autori

`designer.initial`, scritta dal job con `inizialeDi()` (`src/lib/iniziale.ts`).
In SQL non si può: SQLite non toglie i diacritici, e su BGG i nomi asiatici
sono scritti `西村裕 (Hiroshi Nishimura)`, con la parte leggibile in mezzo alla
stringa. La funzione toglie gli accenti, mappa le latine che NFD non scompone
(Ø, Ł, Đ, Æ…) e poi prende la prima lettera latina che trova. I bottoni della
rubrica restano le nostre 26 lettere più `#`, dove finisce solo chi una
lettera non ce l'ha proprio: erano 96 autori, ora sono 3.

## Cosa resta da fare

**Tre categorie senza traduzione**: Fan Expansion, Game System, Third-party
Expansion.
