diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a4e8cb1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "enabledPlugins": { + "vercel@claude-plugins-official": true, + "supabase@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true + } +} diff --git a/.cursor/rules/crapp.mdc b/.cursor/rules/crapp.mdc new file mode 100644 index 0000000..32b9f25 --- /dev/null +++ b/.cursor/rules/crapp.mdc @@ -0,0 +1,16 @@ +--- +description: Regole di progetto CrAPP +alwaysApply: true +--- + +Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolanti e valgono +per intero. + +- Non implementare funzionalità non documentate in `docs/`. +- Lavora su `develop`, mai direttamente su `main`. +- Codice, commenti e documentazione in italiano. + +Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche +Claude Code e Codex. Vale per qualsiasi aggiunta o modifica — regola, funzionalità, decisione, +schema database: prima di considerare finito il lavoro esegui la checklist «Fine lavoro» di +`AGENTS.md`. diff --git a/.gitignore b/.gitignore index 702c652..7df29fe 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ dist-ssr # Optional .vercel +# Supabase CLI local state +supabase/.temp/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 36eb109..45a6116 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,450 @@ - -> [!IMPORTANT] -> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting -> published git history — force pushing, or rebasing/amending/squashing commits -> that are already pushed — as it rewrites history on Lovable's side and the -> user will likely lose their project history. -> -> Commits you push to the connected branch sync back to Lovable and show up in -> the editor, so keep the branch in a working state. - +# CrAPP - AI Development Guide + +Questo documento definisce le regole che qualsiasi assistente AI (Cursor, Claude Code, Codex, ChatGPT o altri) deve seguire quando lavora su questo progetto. + +--- + +# Obiettivo del progetto + +CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo. + +L'obiettivo principale è: + +- ridurre il lavoro amministrativo degli amministratori; + +- aumentare il coinvolgimento dei giocatori; + +- centralizzare tutte le informazioni della squadra; + +- utilizzare l'intelligenza artificiale solo quando porta un reale beneficio. + +--- + +# Prima di modificare il codice + +Prima di implementare qualsiasi modifica leggere sempre: + +1. docs/[README.md](http://README.md) + +2. docs/[VISION.md](http://VISION.md) + +3. docs/[ROADMAP.md](http://ROADMAP.md) + +4. docs/[ARCHITECTURE.md](http://ARCHITECTURE.md) + +5. docs/[DATABASE.md](http://DATABASE.md) + +6. docs/DESIGN_[DECISIONS.md](http://DECISIONS.md) + +7. docs/[TODO.md](http://TODO.md) + +8. il documento interessato in docs/modules/ + +Inoltre, prima di iniziare una nuova attività: + +- verificare lo stato attuale del repository; + +- controllare le modifiche e i commit recenti; + +- verificare eventuali modifiche introdotte da altri sviluppatori o assistenti AI; + +- leggere la documentazione aggiornata relativa alla funzionalità interessata. + +Non implementare funzionalità non documentate. + +Non presumere che il progetto sia nello stesso stato dell'ultima sessione o conversazione. + +--- + +# Workflow di sviluppo + +Ogni nuova funzionalità segue sempre questo processo. + +Idea + +↓ + +Progettazione + +↓ + +Documentazione + +↓ + +Database + +↓ + +Implementazione + +↓ + +Test + +↓ + +Pull Request + +↓ + +Merge su develop + +↓ + +Verifica + +↓ + +Merge su main + +↓ + +Deploy + +Le funzionalità possono essere sviluppate in parallelo da persone diverse, ciascuna sul proprio branch. + +--- + +# Git + +Il repository utilizza due branch principali. + +## main + +Versione stabile. + +Qualsiasi modifica deve mantenere l'app perfettamente funzionante. + +`main` rappresenta la versione destinata alla produzione. + +## develop + +Branch di integrazione e test. + +Le nuove funzionalità vengono integrate in `develop` prima di arrivare in `main`. + +Non lavorare direttamente su `main`. + +Evitare modifiche dirette a `develop`, salvo attività esplicitamente concordate. + +--- + +# Branch di sviluppo + +Ogni sviluppatore deve lavorare su un branch dedicato creato a partire da `develop`. + +Esempi: + +- `feature/profilo-giocatore` + +- `feature/integrazione-csi` + +- `fix/presenze` + +- `refactor/supabase-client` + +Non utilizzare lo stesso branch contemporaneamente per attività indipendenti. + +Prima di iniziare un'attività verificare che il branch sia aggiornato rispetto a `develop`. + +--- + +# Integrazione delle modifiche + +Le modifiche significative devono essere integrate tramite Pull Request verso `develop`. + +Una Pull Request dovrebbe permettere di capire: + +- cosa è stato modificato; + +- perché è stato modificato; + +- quali file o moduli sono coinvolti; + +- se il database è stato modificato; + +- quali test sono stati eseguiti; + +- eventuali rischi o conseguenze. + +Prima del merge verificare eventuali conflitti con il lavoro sviluppato nel frattempo dagli altri collaboratori. + +--- + +# Tracciabilità delle modifiche + +Ogni modifica significativa deve lasciare una traccia nel progetto. + +Devono essere utilizzati: + +- commit con messaggi descrittivi; + +- Pull Request per l'integrazione; + +- [CHANGELOG.md](http://CHANGELOG.md) quando una modifica deve essere registrata nella cronologia del progetto; + +- PROJECT_[STATE.md](http://STATE.md) quando cambia lo stato generale del progetto; + +- DESIGN_[DECISIONS.md](http://DECISIONS.md) per decisioni architetturali significative. + +La documentazione deve permettere a uno sviluppatore o a un assistente AI di ricostruire cosa è successo senza dipendere dalla cronologia delle conversazioni. + +--- + +# Aggiornamento del contesto dopo la sincronizzazione + +Quando vengono scaricate modifiche da GitHub, l'assistente AI deve considerare il repository come fonte di verità. + +Prima di iniziare una nuova attività deve: + +1. verificare i nuovi commit; + +2. identificare le modifiche rilevanti; + +3. leggere la documentazione modificata; + +4. verificare eventuali modifiche al database; + +5. tenere conto delle nuove decisioni architetturali. + +Non ignorare modifiche introdotte da altri collaboratori. + +Non sovrascrivere modifiche esistenti senza averne compreso lo scopo. + +--- + +# Architettura + +Frontend + +- React + +- TypeScript + +- TanStack Start + +- Tailwind CSS + +Backend + +- Supabase + +Hosting + +- Vercel + +Repository + +- GitHub + +--- + +# Database + +Il database utilizza Supabase. + +Regole: + +- non eliminare tabelle esistenti; + +- non modificare lo schema senza creare una migration; + +- preferire strutture scalabili; + +- evitare duplicazione dei dati; + +- non modificare migration già applicate; + +- ogni modifica allo schema deve essere rappresentata da una nuova migration. + +Fare sempre riferimento a: + +docs/[DATABASE.md](http://DATABASE.md) + +--- + +# Componenti + +Preferire: + +- componenti piccoli; + +- componenti riutilizzabili; + +- responsabilità singola; + +- codice semplice da mantenere. + +Evitare duplicazioni. + +Prima di creare un nuovo componente verificare se esiste già un componente riutilizzabile. + +--- + +# Interfaccia + +Lo stile dell'app deve rimanere coerente. + +Principi: + +- semplice; + +- moderna; + +- pulita; + +- veloce; + +- ottimizzata per smartphone; + +- poche schermate; + +- pochi click. + +--- + +# Documentazione + +Ogni nuova funzionalità deve essere documentata prima dello sviluppo. + +La documentazione dei moduli si trova in: + +docs/modules/ + +Aggiornare sempre, quando necessario: + +- [ROADMAP.md](http://ROADMAP.md) + +- [CHANGELOG.md](http://CHANGELOG.md) + +- [TODO.md](http://TODO.md) + +- [DATABASE.md](http://DATABASE.md) (se il database cambia) + +- DESIGN_[DECISIONS.md](http://DECISIONS.md) (se si prende una decisione architetturale importante) + +- PROJECT_[STATE.md](http://STATE.md) (se cambia lo stato generale del progetto) + +--- + +# Struttura della documentazione + +La cartella `docs/` rappresenta la documentazione ufficiale del progetto. + +## Documenti principali + +- [README.md](http://README.md) → panoramica del progetto + +- [VISION.md](http://VISION.md) → obiettivi e filosofia + +- [ROADMAP.md](http://ROADMAP.md) → evoluzione prevista + +- [ARCHITECTURE.md](http://ARCHITECTURE.md) → architettura tecnica + +- [DATABASE.md](http://DATABASE.md) → struttura del database + +- DESIGN_[DECISIONS.md](http://DECISIONS.md) → registro delle decisioni di progetto + +- [CHANGELOG.md](http://CHANGELOG.md) → cronologia delle modifiche + +- [TODO.md](http://TODO.md) → attività pianificate + +- PROJECT_[STATE.md](http://STATE.md) → stato attuale del progetto + +## Moduli + +La cartella `docs/modules/` contiene una specifica funzionale per ogni modulo dell'applicazione. + +Ogni nuovo modulo deve essere progettato e documentato prima dell'implementazione. + +--- + +# Regola anti-regressione + +Le nuove versioni devono principalmente aggiungere funzionalità. + +Non riscrivere o modificare profondamente moduli già funzionanti senza una motivazione esplicita e una verifica degli impatti. + +Evitare refactoring trasversali durante lo sviluppo di nuove funzionalità, salvo quando sono necessari per la funzionalità stessa. + +Prima di modificare un modulo esistente verificare quali altre parti dell'app lo utilizzano. + +--- + +# Regole per il database e le migration + +Le migration già applicate sono parte della storia del database e non devono essere riscritte. + +Per modificare il database: + +1. progettare la modifica; + +2. documentarla quando necessario; + +3. creare una nuova migration; + +4. testarla; + +5. applicarla all'ambiente di sviluppo; + +6. verificare l'assenza di regressioni; + +7. solo successivamente applicarla all'ambiente di produzione. + +--- + +# Regole + +L'AI non deve: + +- introdurre librerie senza necessità; + +- modificare il database senza motivazione; + +- eliminare funzionalità esistenti; + +- modificare il comportamento dell'app senza richiesta esplicita; + +- sovrascrivere modifiche di altri collaboratori senza comprenderle; + +- riscrivere migration già applicate; + +- lavorare direttamente su `main`; + +- assumere che il repository sia invariato rispetto all'ultima sessione. + +L'AI deve: + +- spiegare le modifiche importanti; + +- mantenere compatibilità con il codice esistente; + +- privilegiare la semplicità; + +- riutilizzare i componenti esistenti; + +- controllare il lavoro recente degli altri collaboratori; + +- mantenere aggiornata la documentazione quando necessario; + +- segnalare conflitti, rischi e possibili regressioni prima di modificare parti sensibili. + +--- + +# Filosofia del progetto + +Prima di scrivere codice chiedersi sempre: + +Questa modifica rende CrAPP più semplice? + +Riduce il lavoro degli amministratori? + +Migliora l'esperienza dei giocatori? + +È coerente con la documentazione? + +Riduce oppure aumenta la complessità futura? + +Se almeno una risposta è negativa, rivalutare la soluzione proposta. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1395b1b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# CLAUDE.md + +Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui. +La documentazione tecnica è indicizzata in [docs/README.md](docs/README.md); l'architettura +in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono +anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code. Vale per qualsiasi aggiunta o +modifica: prima di dire che hai finito, esegui la checklist «Fine lavoro» di `AGENTS.md`. + +## Comandi + +```bash +npm run dev # vite dev su http://localhost:8080 +npm run build # build di produzione (nitro) +npm run lint # eslint (include prettier come regola) +npm run format # prettier --write . +npm run test # suite di test (test/); npm run test:all per quella completa + +npx supabase start # database locale in Docker (migration applicate + seed) +npx supabase stop # spegne i container +npx supabase db reset # ricrea il database locale da zero +npx supabase db push # applica le migration al progetto cloud +``` + +Verifica minima prima di consegnare: `npm run lint` + `npm run test`. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md new file mode 100644 index 0000000..2344f21 --- /dev/null +++ b/PROJECT_STATE.md @@ -0,0 +1,109 @@ +# Project State + +Ultimo aggiornamento: 30/08/2026 + +## Stato generale + +Fase corrente: + +Backend migrato al nuovo Supabase proprietario. M1 completata. M2 e M3 scritte e da applicare. +Autenticazione Google, dashboard amministratore e Profilo Giocatore (lato giocatore e lato +admin) implementati su `develop`, da attivare in produzione seguendo i passaggi più sotto. + +--- + +## Infrastruttura + +- GitHub configurato con branch `main` e `develop` +- Cursor come ambiente di sviluppo +- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production) +- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes` +- 12 migration locali applicate con successo al nuovo database +- Sviluppo locale verificato con il nuovo Supabase +- Preview Vercel di `develop` verificata con successo (presenza scritta su `risposte_presenze` confermata nel nuovo database) +- Produzione (`main`): non ancora verificata in questa fase + +--- + +## Backend + +- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`) +- Lovable Cloud: non più backend operativo di CrAPP +- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare + +--- + +## Database + +- Schema v1.0 + M1 applicati al nuovo Supabase +- `public.giocatori_squadra`: 17 giocatori iniziali presenti + +--- + +## Moduli completati + +- Squadra +- Presenze +- Badge +- Scout Live +- Pagelle +- MVP +- Notifiche +- Profilo Giocatore (su `develop`, specifica in `docs/modules/profilo-giocatore.md`) + +--- + +## Autenticazione e dashboard amministratore + +Implementate su `develop`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione +libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i +permessi di amministrazione arrivano solo da `user_roles`. + +**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con +Google» risponde + +``` +{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"} +``` + +e **nessuno entra nell'app**, né in dev né sulla preview di `develop`. Il passo 1 qui sotto +va fatto prima di mandare questa versione in produzione. + +Passaggi in ordine, nessuno dei quali è reversibile a metà: + +1. **Provider Google in Supabase** — Google Cloud Console: consent screen *External* (scope + `email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità *Testing* + regge fino a 100 utenti, più che sufficiente per la squadra), credenziale + *Web application* con redirect URI + `https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e + secret in *Authentication → Providers → Google*. In *URL Configuration*: Site URL di + produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs. + Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in + `[auth.external.google]` di `supabase/config.toml`, le due variabili + `SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI + `http://127.0.0.1:54321/auth/v1/callback`. +2. **Migration M2 e M3** (`supabase db push`). Sono `CREATE` puri: si possono applicare in + produzione senza toccare il comportamento attuale. +3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento): + `INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '';` +4. **Collegamento dei 17 account**: ciascuno accede con Google e sceglie il proprio nome una + volta sola. Uno slot già collegato può essere liberato solo da un admin. +5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon` + l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione; + le route in `src/routes/api/public/` usano la service role e continuano a funzionare. + +Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che +collega uno slot lo occupa anche in produzione, e va liberato da un admin. + +## Prossimo sviluppo + +Gestione tesseramenti CSI: la raccolta dati e l'export CSV sono pronti, manca il +tracciamento di chi è già tesserato (numero e data di tessera). + +--- + +## Note + +Il progetto segue una metodologia document-first. + +Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata. diff --git a/README.md b/README.md index 6510c88..1d1cc9a 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,95 @@ -# CRAP Volley Hub +# CrAPP 🏐 -CrAPP – App per CRAP Volley +CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo. -Vorrei sviluppare un’app mobile per la squadra di pallavolo CRAP Volley, con nome CrAPP, disponibile per Android e iOS. L’obiettivo è creare un’app semplice da usare, moderna, bella da vedere e più coinvolgente rispetto a SportEasy, includendo anche funzionalità normalmente a pagamento in altre app. +## Funzionalità principali -Funzionalità principali +- Gestione squadra +- Gestione presenze +- Calendario allenamenti e partite +- Scout Live +- Badge e gamification +- Statistiche +- Notifiche intelligenti +- Gestione amministrativa +- AI per la pianificazione degli allenamenti (in sviluppo) -Gestione presenze/assenze +--- -Partite +## Stack tecnologico -Allenamenti +- React 19 +- TypeScript +- TanStack Start +- Vite +- Tailwind CSS +- Supabase +- GitHub +- Vercel -Eventi extra +--- -Stati rapidi: presente, assente, forse, in ritardo, indisponibile, infortunato +## Ambienti -Statistiche giocatori +- `main` → Produzione +- `develop` → Sviluppo -Presenze totali +--- -Presenze consecutive +## Avvio locale -Gol/punti o altre statistiche specifiche della pallavolo - -MVP, migliori performance, medie stagione - -Statistiche partite - -Risultati - -Formazioni - -Andamento set - -Storico match - -Campionato in tempo reale - -Visualizzazione classifica e risultati - -Dati presi direttamente dal sito del CSI - -Aggiornamento automatico o importazione periodica - -Calendario squadra - -Allenamenti - -Partite - -Promemoria - -Vista mensile e lista eventi - -Profilo giocatore - -Foto - -Ruolo - -Statistiche personali - -Badge e obiettivi - -Idea di stile - -Interfaccia sportiva, pulita e moderna - -Molto mobile-first - -Design divertente, energico e più “premium” - -Inserire in seguito il logo della squadra - -Possibile uso di badge, livelli, premi e mini-gamification per rendere l’app più piacevole da usare - -Extra che sarebbe bello aggiungere - -Notifiche push per convocazioni e cambi orario - -Chat o bacheca squadra - -Report automatici dopo le partite - -Sondaggi rapidi per disponibilità - -Sezione “Best of the match” - -Obiettivi di gruppo per presenza e continuità - -Obiettivo finale - -Realizzare una app che non sia solo utile per la gestione della squadra, ma anche piacevole, coinvolgente e bella da usare ogni giorno. - -This project was built with [Lovable](https://lovable.dev). - -**Live app**: https://volley-cronos-app.lovable.app - -## Build with Lovable - -Continue developing this project in the [Lovable editor](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c). - -- **Ship faster**: describe what you want to build and Lovable handles the code. -- **Stay in sync**: every change made in Lovable is committed straight to this repository. -- **Full ownership**: this code is yours. Push to `main` on GitHub and your changes sync back into Lovable, ready for your next prompt. - -## Development - -Prefer working locally? You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating). - -```sh -git clone -cd -npm i +```bash +npm install npm run dev ``` + +L'app sarà disponibile su: + +``` +http://localhost:8080 +``` + +--- + +## Build + +```bash +npm run build +``` + +--- + +## Deploy + +Il deploy è automatico tramite Vercel ad ogni push sul branch `main`. + +Le modifiche sviluppate nel branch `develop` vengono pubblicate automaticamente come Preview Deployment. + +--- + +## Variabili d'ambiente + +Il progetto richiede le seguenti variabili: + +- `SUPABASE_URL` +- `SUPABASE_PUBLISHABLE_KEY` +- `VITE_SUPABASE_URL` +- `VITE_SUPABASE_PUBLISHABLE_KEY` + +--- + +## Repository + +Il codice sorgente è gestito tramite GitHub. + +Flusso di sviluppo: + +``` +develop + ↓ +Test + ↓ +Merge su main + ↓ +Deploy automatico Vercel +``` \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ac21a45 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,123 @@ +# Architettura del progetto + +Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di +riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama. + +## Stack + +| Livello | Tecnologie | +|---|---| +| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn | +| Backend | Supabase (PostgreSQL, Auth, Storage) | +| Hosting | Vercel | +| Versionamento | Git, GitHub | + +Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone +`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a +`minimumReleaseAgeExcludes` richiede conferma esplicita. + +## Struttura del progetto + +``` +src/ + components/ componenti condivisi (crapp/, ui/, motion/) + routes/ routing file-based + lib/ logica di dominio, un file per modulo + integrations/ client Supabase e integrazioni esterne + hooks/ + assets/ +supabase/ migration SQL +test/ suite di test (unit, integration, end-to-end) +docs/ documentazione ufficiale +``` + +## Punti fermi + +- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si + modifica a mano. +- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che + include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias + `@` → `src/`. **Non ri-aggiungere questi plugin**: l'app si rompe. +- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare + gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza + `renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui + server functions, `attachSupabaseAuth`). +- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file + generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo + schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite + `client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`. +- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica + strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità + del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di + amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`). + +## Livello dati + +Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`, +`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern +ricorrente: + +- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in + `src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` + disattivati, `retry: 1`); +- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con + `invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica + eccezione oggi: `scout-live.ts`); +- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs + `palloni.ts`, `mediePagelle()` vs `usePagelle()`); +- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa + con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le + route consumano `useRosa()`, non i singoli moduli. + +Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il +backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)). + +Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di +`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md). + +Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La +gamification deve restare equa tra ruoli (DD-008). + +La rosa è tuttora **hardcoded** in `src/lib/crapp-data.ts` (`rosaCSI`); la migrazione verso +la tabella `giocatori_squadra` è in corso — vedi DD-015 e DD-016. + +## UI + +Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`, +`StatTile`), primitive shadcn in `src/components/ui/`, animazioni in +`src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click. + +## Comandi + +```bash +npm run dev # vite dev su http://localhost:8080 +npm run build # build di produzione (nitro) +npm run lint # eslint (include prettier come regola) +npm run format # prettier --write . +npm run test # test unit (veloci, senza rete né database) +npm run test:integration # route server vere +npm run test:e2e # percorsi sull'app servita +npm run test:all # tutto +``` + +Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud: + +```bash +npx supabase start # avvia lo stack locale e applica tutte le migration +npx supabase stop # spegne i container +npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql +npx supabase db push # applica le migration al progetto cloud +``` + +`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché +il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto +dove provare le migration distruttive senza toccare i dati veri. + +## Branch e flusso di sviluppo + +- `main` → produzione, deploy automatico su Vercel. +- `develop` → sviluppo; si lavora qui, mai direttamente su `main` (DD-003). + +``` +develop → test → merge su main → deploy automatico su Vercel +``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..f38f9d9 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +Tutte le modifiche significative del progetto vengono registrate in questo documento, in +ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete +qui: sta in [ROADMAP.md](ROADMAP.md). + +## Versione attuale — agosto 2026 + +### Autenticazione e dashboard amministratore (su `develop`, non ancora in produzione) + +- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a + un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal + giocatore stesso (DD-016 regola 2). +- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con + Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile + `VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`. +- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista + di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto + per amministrare. +- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0. + **Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede + l'app vuota. +- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica + documento, certificato medico e foto tessera con le relative scadenze + ([modules/profilo-giocatore.md](modules/profilo-giocatore.md)). +- Nuova schermata `/admin`: stato dei profili della squadra, download di documento, + certificato e foto tessera, export CSV per il tesseramento CSI. +- Migration `m2_profili_giocatore` (tabella dei profili) e `m3_bucket_profili` (bucket + privato), entrambe additive. +- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo), + compila i dati personali al posto di un giocatore e scollega un account da un profilo + (DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le + policy di M1 e M2 lo consentivano già. + +### Test + +- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun, + senza nuove dipendenze: `npm run test` e `npm run test:all`. +- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp + illeggibile restava bloccata per sempre invece di scadere. + +### Collegamento CSI + +- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna + (stagione 2025/26, Campionato Open Misto Eccellenza, Girone B). +- La pagina Campionato non usa più dati dimostrativi. +- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md). + +### Infrastruttura + +- Migrazione completa da Lovable a sviluppo locale. +- Configurazione Git. +- Repository GitHub indipendente. +- Deploy automatico tramite Vercel. +- Branch main e develop. + +## Versione 1.0 — luglio 2026 + +Prima versione usata dalla squadra. Funzionalità incluse: vedi +[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata). diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..849b099 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,66 @@ +# Database CrAPP + +Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo +sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella +stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md), +non in questo file. + +## Anagrafica e utenti + +| Tabella | Scopo | Note | +|---|---|---| +| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento all'account (`auth_user_id`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. | +| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). | +| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. | +| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). | + +`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle. + +## Storage + +| Bucket | Scopo | Note | +|---|---|---| +| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`/.`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m3_bucket_profili`. | + +## Eventi e presenze + +| Tabella | Scopo | Note | +|---|---|---| +| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. | +| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. | +| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). | +| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | + +## Scout + +| Tabella | Scopo | Note | +|---|---|---| +| `scout_sessioni` | Sessioni di Scout Live: una sessione corrisponde a una partita. | **Non ancora usata dal codice**: oggi lo stato della sessione vive in `localStorage` (`src/lib/scout-live.ts`, `scout-store.ts`) e sul database finiscono solo le azioni in `scout_live`. | +| `scout_live` | Eventi registrati durante lo Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). | + +## Votazioni + +| Tabella | Scopo | Note | +|---|---|---| +| `mvp_voti` | Voti MVP assegnati a fine partita. | | +| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. | +| `badge_social_voti` | Voti social per i badge. | | + +## Turni e notifiche + +| Tabella | Scopo | Note | +|---|---|---| +| `turni_palloni` | Gestione dei turni palloni. | | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Storico dei promemoria inviati. | | + +## Funzioni speciali + +| Tabella | Scopo | Note | +|---|---|---| +| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. | + +## Badge + +Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a +partire dai dati esistenti (DD-007). diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md new file mode 100644 index 0000000..ce954cf --- /dev/null +++ b/docs/DESIGN_DECISIONS.md @@ -0,0 +1,517 @@ +# Registro delle decisioni di progetto + +Questo documento raccoglie le **decisioni importanti** prese nel corso della vita di CrAPP: scelte che hanno influito sulla direzione del prodotto, sull’organizzazione del lavoro o su come l’app si evolve nel tempo. + +Non descrive *come* è fatto il codice. Per quello esistono `ARCHITECTURE.md` e `DATABASE.md`. + +Serve a rispondere a domande del tipo: + +- *Perché abbiamo scelto così?* +- *Cosa avevamo escluso e perché?* +- *Quando conviene riaprire una decisione?* + +--- + +## Indice + +**Accettate** + +| ID | Titolo | +|---|---| +| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable | +| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first | +| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop | +| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive | +| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first | +| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile | +| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB | +| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli | +| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 | +| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 | +| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo | +| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 | +| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack | +| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 | +| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore | + +**In valutazione** + +| ID | Titolo | +|---|---| +| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB | +| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB | + +--- + +## Come usare questo registro + +Ogni decisione segue lo stesso schema: + +| Campo | Significato | +|---|---| +| **Data** | Quando la decisione è stata presa o confermata | +| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta | +| **Contesto** | Quale problema o opportunità avevamo di fronte | +| **Decisione** | Cosa abbiamo scelto di fare | +| **Alternative scartate** | Cosa non abbiamo fatto e perché | +| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) | +| **Riesame** | Quando ha senso riconsiderarla | + +**Quando aggiungere una voce** + +- una scelta influisce su più moduli o su più release; +- escludiamo un’alternativa non ovvia; +- accettiamo un compromesso consapevole (debito, limitazione, ritardo); +- cambiamo una decisione precedente. + +**Quando non serve** + +- dettagli implementativi locali; +- scelte estetiche minori; +- bugfix o correzioni puntuali. + +**Come registrare una nuova decisione** + +Copiare [`_template-dd.md`](_template-dd.md) in fondo al documento, assegnare il primo ID +libero e aggiungerlo all'indice. + +--- + +## Decisioni accettate + +--- + +### DD-001 — CrAPP deve restare indipendente da Lovable + +**Data:** luglio 2026 +**Stato:** Accettata + +**Contesto** +Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi. + +**Decisione** +Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team. + +**Alternative scartate** +- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno. +- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già. + +**Conseguenze** +- Maggiore libertà e responsabilità per il team. +- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco. +- L’app deve poter girare anche fuori dall’ecosistema Lovable (vedi `PORTABILITA.md`). + +**Riesame** +Quando il progetto non userà più alcun componente Lovable. + +--- + +### DD-002 — Sviluppo document-first + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +Con più persone (e assistenti AI) che lavorano sul codice, serviva un modo per evitare funzionalità “inventate” al volo e incoerenze tra moduli. + +**Decisione** +Ogni nuova funzionalità significativa viene prima **progettata e documentata** in `docs/modules/`, poi implementata. Il flusso ufficiale è: idea → progettazione → documentazione → database → codice → test → release. + +**Alternative scartate** +- Documentare solo a posteriori → troppo spesso incompleto o assente. +- Affidarsi solo al codice come documentazione → illeggibile per chi non programma. + +**Conseguenze** +- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite. +- I moduli v1.0 vanno retro-documentati quando possibile. +- Nessuna feature non documentata entra in produzione. + +**Riesame** +Se il team diventa molto piccolo e la documentazione smette di essere consultata. + +--- + +### DD-003 — Due branch: main stabile, develop per il lavoro + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +Serve separare ciò che i giocatori usano ogni giorno da ciò che è ancora in prova. + +**Decisione** +- `main` → produzione, sempre funzionante, deploy automatico. +- `develop` → sviluppo e preview, merge su `main` solo dopo test. + +**Alternative scartate** +- Sviluppare direttamente su `main` → rischio di rotture in produzione. +- Branch per ogni feature → eccessivo per la dimensione attuale del team. + +**Conseguenze** +- Gli utenti in produzione non vedono lavori incompleti. +- Ogni release su `main` deve includere verifica delle funzionalità esistenti. + +**Riesame** +Se il team cresce e servono review più granulari (pull request per feature). + +--- + +### DD-004 — Ogni versione aggiunge, non riscrive + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +CrAPP v1.0 è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti. + +**Decisione** +Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata. + +**Alternative scartate** +- Refactoring ampio “per pulire” insieme a ogni release → alto rischio, poco valore immediato per gli utenti. + +**Conseguenze** +- Coesistono temporaneamente soluzioni vecchie e nuove (es. dati hardcoded accanto a tabelle database). +- Il debito tecnico va gestito con migration dedicate, non di nascosto. + +**Riesame** +Quando un modulo diventa ingestibile o blocca una release importante. + +--- + +### DD-005 — Mobile-first, pochi click, pochi schermi + +**Data:** origine progetto +**Stato:** Accettata + +**Contesto** +I giocatori usano l’app soprattutto da smartphone, spesso in spogliatoio o in palestra, con poco tempo e poca pazienza. + +**Decisione** +Interfaccia semplice, veloce, ottimizzata per telefono. Navigazione ridotta (barra inferiore). Ogni schermata deve avere uno scopo chiaro. + +**Alternative scartate** +- Layout da desktop con menu complessi → scomodo in mobilità. +- App nativa iOS/Android → costi e tempi di pubblicazione non giustificati per una squadra amatoriale. + +**Conseguenze** +- Funzionalità amministrative complesse vanno semplificate o suddivise con cura. +- La PWA è la forma giusta per questo pubblico. + +**Riesame** +Se emergono esigenze desktop forti (es. gestione documenti massiva solo da PC). + +--- + +### DD-006 — Intelligenza artificiale solo se porta beneficio reale + +**Data:** origine progetto +**Stato:** Accettata + +**Contesto** +L’AI è attraente ma può complicare l’app, aumentare i costi e creare aspettative irrealistiche. + +**Decisione** +Usare l’AI solo quando riduce lavoro agli admin o migliora concretamente l’esperienza dei giocatori. Non introdurla “perché si può”. + +**Alternative scartate** +- AI ovunque (chatbot, suggerimenti automatici, analisi predittive) → fuori focus per una squadra amatoriale. + +**Conseguenze** +- “AI Allenamenti” è in roadmap v1.2, non v1.1. +- Ogni proposta AI va valutata con la domanda: *chi risparmia tempo e quanto?* + +**Riesame** +Quando l’AI diventa economica e affidabile per casi d’uso chiari (es. generazione allenamenti). + +--- + +### DD-007 — Badge calcolati dall’app, non salvati nel database + +**Data:** origine progetto +**Stato:** Accettata + +**Contesto** +I badge dipendono da statistiche già disponibili (presenze, MVP, cacche, ecc.). Salvare ogni badge sbloccato nel database aggiungerebbe complessità senza beneficio immediato. + +**Decisione** +I badge vengono **calcolati al volo** dall’applicazione in base ai dati esistenti. Non esiste una tabella badge dedicata. + +**Alternative scartate** +- Tabella `badge_sbloccati` con storico → utile in futuro per notifiche retroattive o audit, ma non necessaria ora. + +**Conseguenze** +- Meno migration e meno sincronizzazione. +- Lo “sblocco” celebrativo usa cache locale per non ripetere animazioni. +- Un eventuale storico badge richiederà una nuova decisione. + +**Riesame** +Se servono badge manuali assegnati dagli admin o storico immutabile. + +--- + +### DD-008 — Gamification equa tra ruoli + +**Data:** origine progetto +**Stato:** Accettata + +**Contesto** +In pallavolo i ruoli hanno statistiche diverse (un libero non segna punti d’attacco). Confrontare tutti sugli stessi numeri sarebbe ingiusto e scoraggiante. + +**Decisione** +Le statistiche **personali** in profilo e squadra devono essere **eque per tutti i ruoli**. Dati tecnici di reparto (punti, ace, muri) restano nello Scout Live come informazione di squadra, non come leva competitiva individuale. + +**Alternative scartate** +- Classifiche individuali basate su punti → penalizza libero, palleggiatore, centrale. + +**Conseguenze** +- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti. +- Lo scout resta strumento tecnico, non gioco. + +**Riesame** +Se la squadra chiede esplicitamente classifiche tecniche per ruolo. + +--- + +### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0 + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente. + +**Decisione** +- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI. +- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale). + +**Alternative scartate** +- Integrazione CSI già in v1.1 → scope troppo ampio, dipendenza da API esterne non controllate. + +**Conseguenze** +- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti). +- L’export CSV deve essere affidabile e completo: è il deliverable chiave della v1.1. + +**Riesame** +Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione. + +--- + +### DD-010 — Profilo giocatore: niente storico certificati in v1 + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy. + +**Decisione** +In v1 il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato. + +**Alternative scartate** +- Archivio certificati → utile per audit, rinviato a versioni future. + +**Conseguenze** +- Implementazione più semplice e veloce. +- Gli admin vedono solo il certificato attuale. +- Va comunicato chiaramente ai giocatori che sostituire il file elimina quello precedente. + +**Riesame** +Se il CSI o il regolamento interno richiedono conservazione storica. + +--- + +### DD-011 — Autenticazione reale prima del profilo amministrativo completo + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +Oggi l’app identifica l’utente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere *chi* sta operando e impedire accessi non autorizzati. + +**Decisione** +Prima di completare il modulo Profilo Giocatore (v1.1), introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra. + +**Alternative scartate** +- Continuare solo con selezione da lista → inaccettabile per dati sensibili. +- Lovable Auth → crea dipendenza da piattaforma che stiamo abbandonando. + +**Conseguenze** +- Tutti dovranno fare login almeno una volta. +- Gli admin useranno ruoli veri (`user_roles`), non una lista di nomi hardcoded. +- È prerequisito per dashboard admin e export CSI. + +**Riesame** +Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali). + +--- + +### DD-012 — Non migrare gli ID giocatore in v1.1 + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +L’app usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale. + +**Decisione** +Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato. + +**Alternative scartate** +- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche. + +**Conseguenze** +- Coesistono due modelli anagrafici fino a migration dedicata. +- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”. + +**Riesame** +Quando la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa. + +--- + +### DD-013 — Portabilità: l’app non deve dipendere da servizi esclusivi + +**Data:** luglio 2026 +**Stato:** Accettata + +**Contesto** +La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro. + +**Decisione** +CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti. + +**Alternative scartate** +- Accettare lock-in per velocità → contrario alla lunga vita del progetto. + +**Conseguenze** +- Supabase va bene perché è PostgreSQL e self-hostable. +- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler. + +**Riesame** +Se si adotta un servizio che viola questa regola. + +--- + +### DD-016 — Schema dati Profilo Giocatore v1.1 (F0) + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con l’anagrafica attuale (`g1`…`g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare l’autenticazione e come proteggere documenti sensibili — senza toccare le tabelle v1.0 già operative. + +**Decisione** +Per la v1.1 si introducono **due nuove tabelle additive**: + +- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`). +- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. + +Regole vincolanti: + +1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati. +2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali. +3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**. +4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin. +5. Le **tabelle v1.0 esistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID in v1.1 (coerente con DD-012). +6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo in v1.1. + +**Alternative scartate** +- Estendere la tabella `giocatori` UUID → conflitto con ID operativi del codice e rischio di regressioni. +- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare. +- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti d’identità. +- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition. +- Modificare tabelle v1.0 per aggiungere FK verso il profilo → viola DD-004 e DD-012. + +**Conseguenze** +- Coesistono temporaneamente tre rappresentazioni dell’anagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente). +- `src/lib/rosa.ts` dovrà leggere prima dal database e ricadere su `crapp-data.ts` in caso di errore o assenza dati. +- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database. +- Lo storico certificati non viene conservato in v1 (coerente con DD-010). +- Le migration M1–M3 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente. +- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce. + +**Riesame** +- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più. +- Quando si pianifica la convergenza verso UUID (DD-012, post v1.1). +- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati. + +--- + +### DD-017 — L'amministratore può compilare i dati al posto del giocatore + +**Data:** agosto 2026 +**Stato:** Accettata + +**Contesto** +Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo. + +**Decisione** +Dalla dashboard amministratore, un admin può: + +1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo); +2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore; +3. **scollegare** un account da un profilo, liberando lo slot. + +Restano fuori, e non cambiano: + +- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile; +- il **giocatore**, che continua a non poter toccare i propri dati squadra. + +**Alternative scartate** +- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto. +- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario. +- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso. + +**Conseguenze** +- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration. +- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore. +- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione). +- Il completamento del profilo smette di essere un indicatore di *chi ha risposto* e diventa un indicatore di *quali dati mancano*, chiunque li abbia inseriti. + +**Riesame** +- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria. +- Se serve tracciare *chi* ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva. + +--- + +## Decisioni in valutazione + +--- + +### DD-014 — Convergenza schema database (eventi e presenze) + +**Data:** — +**Stato:** In valutazione + +**Contesto** +Esistono due modelli paralleli: tabelle “legacy” usate dall’app (`eventi_app`, `risposte_presenze`) e tabelle “nuove” con autenticazione e vincoli (`eventi`, `presenze`, `giocatori` UUID). + +**Decisione proposta** +Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili. + +**Perché non ora** +Rischio regressioni su calendario e presenze, moduli più usati della squadra. + +**Riesame previsto** +Post v1.1, con migration e test dedicati. + +--- + +### DD-015 — Rosa anagrafica: da codice hardcoded a database + +**Data:** — +**Stato:** In valutazione + +**Contesto** +La lista giocatori vive ancora nel codice sorgente. Il database ha già una tabella popolata ma non usata. + +**Decisione proposta** +Spostare l’anagrafica su database, mantenendo gli stessi ID finché non si fa DD-012. + +**Perché non ora** +Il profilo v1.1 può agganciarsi agli ID attuali; la migrazione rosa può essere fase 2. + +**Riesame previsto** +In parallelo o subito dopo il rollout auth. + +--- diff --git a/docs/EFFICIENZA_CLOUD.md b/docs/EFFICIENZA_CLOUD.md new file mode 100644 index 0000000..21a0d5b --- /dev/null +++ b/docs/EFFICIENZA_CLOUD.md @@ -0,0 +1,36 @@ +# Efficienza cloud + +Regola di progetto: CrAPP gira su un piano cloud minimo (Supabase + Vercel) per ~17 utenti. +Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ottimizzati dopo. + +## Regole da rispettare + +1. **Niente polling**: mai `refetchInterval` verso il database. Per sincronizzare più schede + aperte si usano `BroadcastChannel` o gli eventi di `storage`. +2. **Cache lunga e passiva**: i default del `QueryClient` stanno in `src/router.tsx` + (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati, + `retry: 1`). Non alzare la frequenza di refetch modulo per modulo. +3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con + `invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi: + `src/lib/scout-live.ts`. +4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati. +5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non + si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già + in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato + letto. +6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno + palloni, esito finale. +7. **Niente funzionalità pesanti**: foto, video, chat. +8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration. + +## Obiettivi non ancora attuati + +Questi punti sono stati definiti come direzione, ma **non sono implementati**: non descrivono +il comportamento attuale. + +- **Dati CSI**: sincronizzazione periodica server-side salvata su una tabella locale, con + l'app che legge solo dal database interno. Oggi la lettura è live dal portale a ogni + richiesta, tramite `/api/public/csi` (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)). +- **Aggregati persistiti**: uno schema con `statistiche_aggregate` e `classifica_csi` è stato + ipotizzato ma non esiste; nessuna di quelle tabelle è in `supabase/migrations/`. Va valutato + con una decisione dedicata, perché tocca DD-007 (badge e statistiche calcolati a runtime). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d5dbd10 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# Documentazione CrAPP + +Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda +precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste +ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)). + +## Dove sta cosa + +| Documento | Risponde a | +|---|---| +| [VISION.md](VISION.md) | Perché esiste CrAPP, quali principi deve rispettare una funzionalità | +| [ROADMAP.md](ROADMAP.md) | Cosa è fatto e cosa è previsto, versione per versione | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo | +| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa | +| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta | +| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio | +| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push | +| [TODO.md](TODO.md) | A cosa si sta lavorando adesso | +| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando | +| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file | + +Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md); +lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md). + +## Ordine di lettura + +Prima di modificare il codice, nell'ordine: questo indice → `VISION.md` → `ROADMAP.md` → +`ARCHITECTURE.md` → `DATABASE.md` → `DESIGN_DECISIONS.md` → `TODO.md` → il documento del +modulo interessato in `modules/`. + +## Regole di manutenzione + +Ogni informazione ha **una sola casa**, per evitare che le copie divergano: + +- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`; +- `CHANGELOG.md` registra *quando* qualcosa è stato rilasciato, non ripete l'elenco; +- `TODO.md` contiene solo il lavoro in corso o imminente, e rimanda alla roadmap; +- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in + `supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea; +- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne + una si copia [\_template-dd.md](_template-dd.md). + +Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`), +niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..230a602 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,53 @@ +# Roadmap + +Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per +il *cosa*: `CHANGELOG.md` registra *quando* una voce è stata rilasciata, `TODO.md` cosa si +sta facendo adesso. + +## Versione 1.0 — rilasciata + +- [x] Gestione squadra +- [x] Calendario +- [x] Presenze +- [x] Serie di presenze +- [x] Scout Live +- [x] Badge +- [x] Badge social +- [x] Pagelle +- [x] Obiettivi di squadra +- [x] Notifiche Push (promemoria intelligenti) + +## Versione 1.1 + +Le voci spuntate sono implementate su `develop` e non ancora attive in produzione: lo stato +di attivazione sta in [PROJECT_STATE.md](../PROJECT_STATE.md). + +- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei + certificati resta un'estensione futura +- [ ] Gestione tesseramenti CSI — raccolta dati ed export CSV pronti, manca il tracciamento + di chi è già tesserato (numero e data di tessera) +- [x] Dashboard amministratore +- [x] Download CSV dati + +## Versione 1.2 + +- [ ] Database esercizi +- [ ] AI Allenamenti +- [ ] Archivio allenamenti + +## Versione 2.0 + +- [x] Collegamento CSI (stagione 2025/26) +- [x] Classifica automatica +- [x] Risultati campionato +- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina + Campionato usa solo quelle giocate + +## Idee future + +- [ ] Gestione quote +- [ ] Calendario Google +- [ ] Backup automatici +- [ ] Analisi statistiche avanzate +- [ ] Widget meteo +- [ ] Analisi Scout con AI diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..8d2839f --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,43 @@ +# TODO + +Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in +[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure. + +## In corso + +- Documentazione tecnica del progetto. +- Autenticazione Google e dashboard amministratore: il codice è completo su `develop` e il + login è ora l'unica via d'accesso. Restano i passaggi di configurazione, in quest'ordine: + provider Google in Supabase (senza, nessuno entra), migration M2/M3, primo admin in + `user_roles`, collegamento dei 17 account, e infine la migration M4 che chiude gli accessi + `anon`. Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md). + +## Prossimo + +- Gestione tesseramenti CSI (roadmap v1.1): la raccolta dati e l'export CSV ci sono, manca + il tracciamento di chi è già tesserato (numero e data di tessera). + +Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo` +in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e +la dashboard amministratore legge quei dati. + +## Debito di documentazione + +Moduli v1.0 in produzione senza scheda in [modules/](modules/) — DD-002 ne prevede la +retro-documentazione: Presenze, Scout Live, Badge, Pagelle, MVP, Palloni, Obiettivi di +squadra, Notifiche, Serie di presenze, Infortuni (`src/lib/infortuni.ts`, usato ma non +documentato in nessun punto). + +Non documentate nemmeno le route API pubbliche in `src/routes/api/public/` (`csi`, +`promemoria-palloni`, `push-config`, `push-messaggio`, `push-subscribe`, +`sollecita-presenze`). + +## Manutenzione ricorrente + +- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27 + (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)). + +## Backlog + +- AI Allenamenti (roadmap v1.2). +- Backup automatici (roadmap, idee future). diff --git a/docs/VISION.md b/docs/VISION.md new file mode 100644 index 0000000..bfe6a71 --- /dev/null +++ b/docs/VISION.md @@ -0,0 +1,32 @@ +# Visione del progetto + +## Missione + +CrAPP nasce con un obiettivo semplice: + +Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi. + +## Principi del progetto + +Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi: + +- Ridurre il lavoro amministrativo. +- Migliorare il coinvolgimento della squadra. +- Centralizzare tutte le informazioni in un'unica piattaforma. +- Automatizzare le attività ripetitive. +- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio. + +## Filosofia + +CrAPP deve essere: + +- Semplice +- Veloce +- Divertente +- Intuitiva +- Accessibile da smartphone +- Utilizzabile anche da persone poco esperte + +## Obiettivo finale + +Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione. \ No newline at end of file diff --git a/docs/_template-dd.md b/docs/_template-dd.md new file mode 100644 index 0000000..7a2d48c --- /dev/null +++ b/docs/_template-dd.md @@ -0,0 +1,20 @@ +### DD-XXX — [Titolo breve della decisione] + +**Data:** +**Stato:** Accettata · In valutazione · Sostituita · Obsoleta + +**Contesto** +[Quale problema stavamo risolvendo?] + +**Decisione** +[Cosa abbiamo scelto?] + +**Alternative scartate** +- [Alternativa 1] → [perché no] +- [Alternativa 2] → [perché no] + +**Conseguenze** +[Cosa cambia per utenti, admin e team di sviluppo] + +**Riesame** +[Quando o in quali condizioni rivedere la decisione] diff --git a/docs/archive/migrations/20260830120000_m2_profili_giocatore.sql b/docs/archive/migrations/20260830120000_m2_profili_giocatore.sql new file mode 100644 index 0000000..97061bb --- /dev/null +++ b/docs/archive/migrations/20260830120000_m2_profili_giocatore.sql @@ -0,0 +1,89 @@ +-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016) +-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti. +-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato +-- `profili-giocatore` creato dalla migration M3. + +CREATE TABLE public.profili_giocatore ( + giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE, + + -- Dati personali richiesti dal tesseramento CSI + data_nascita date, + luogo_nascita text, + indirizzo text, + telefono text, + email text, + + -- Documento di identità + documento_tipo text, + documento_numero text, + documento_rilasciato_da text, + documento_emissione date, + documento_scadenza date, + -- Il documento si carica fronte e retro: il CSI li vuole entrambi. + documento_fronte_path text, + documento_retro_path text, + + -- Certificato medico (storico non conservato in v1: DD-010) + certificato_scadenza date, + certificato_path text, + + -- Foto tessera + foto_path text, + + creato_il timestamptz NOT NULL DEFAULT now(), + aggiornato_il timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.profili_giocatore IS + 'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.'; + +CREATE TRIGGER update_profili_giocatore_aggiornato_il + BEFORE UPDATE ON public.profili_giocatore + FOR EACH ROW + EXECUTE FUNCTION public.update_aggiornato_il(); + +ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY; + +-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa +-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1). +CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore + FOR INSERT TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore + FOR UPDATE TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento. +CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated; +GRANT ALL ON public.profili_giocatore TO service_role; diff --git a/docs/archive/migrations/20260830120100_m3_bucket_profili.sql b/docs/archive/migrations/20260830120100_m3_bucket_profili.sql new file mode 100644 index 0000000..4a26afd --- /dev/null +++ b/docs/archive/migrations/20260830120100_m3_bucket_profili.sql @@ -0,0 +1,36 @@ +-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4) +-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e +-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene +-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin. + +INSERT INTO storage.buckets (id, name, public) +VALUES ('profili-giocatore', 'profili-giocatore', false) +ON CONFLICT (id) DO NOTHING; + +-- Convenzione dei path: `/.` (es. `g4/certificato.pdf`). +-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file. +CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects + FOR ALL TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + bucket_id = 'profili-giocatore' + AND EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid() + ) + ); + +-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano +-- in mano al giocatore che li ha caricati. +CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects + FOR SELECT TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND public.has_role(auth.uid(), 'admin'::public.app_role) + ); diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md new file mode 100644 index 0000000..7e3bf65 --- /dev/null +++ b/docs/modules/collegamento-csi.md @@ -0,0 +1,96 @@ +# Modulo — Collegamento CSI + +**Stato:** implementato (stagione 2025/26) +**Route interessata:** `/classifica` + +--- + +## Obiettivo + +Mostrare nell'app la classifica e i risultati **ufficiali** del campionato CSI, al posto +dei dati dimostrativi hardcoded in `crapp-data.ts`. Nessun inserimento manuale da parte +degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere. + +--- + +## Sorgente dati + +Portale **Livescore CSI Bologna** (`https://livescore.csibologna.it`). + +Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi endpoint +che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza +API key, ma **non offrono alcuna garanzia di stabilità**. + +| Endpoint | Formato | Uso | +|---|---|---| +| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi | +| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali | + +Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le +gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`, +`project-last_results.php`, `team-roster.php`, `team-results.php`. + +### Identificativi (stagione 2025/26) + +| Cosa | Valore | +|---|---| +| Campionato | PVM - Campionato Open Misto Eccellenza | +| `project_id` | `767` | +| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | +| `team_id` | `3359` | +| Girone | B | + +Gli identificativi sono costanti in `src/lib/csi-core.ts`. + +--- + +## Implementazione + +``` +CSI (portale) + ↓ fetch server-side, cache 6 ore +/api/public/csi → src/routes/api/public/csi.ts + ↓ JSON { classifica, partite, girone, aggiornato } +useCsi() → src/lib/csi.ts (React Query, staleTime 6h) + ↓ +/classifica → src/routes/classifica.tsx +``` + +- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe), + `partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`. +- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di + 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno). +- **`src/lib/csi.ts`** — hook client, una lettura per sessione. +- **`src/lib/csi-core.test.ts`** — check del parsing: `bun src/lib/csi-core.test.ts`. + Con `CSI_LIVE=1` verifica anche gli endpoint reali. + +### Regole rispettate + +- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo + 4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo). +- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella. +- **Fallback**: se il CSI non risponde, `/classifica` mostra i dati esistenti come prima. +- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo. + +--- + +## Limiti noti + +1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il + parsing restituisce un array vuoto e l'app ricade sui dati demo — non si rompe, ma i dati + sono vecchi. Il check con `CSI_LIVE=1` serve a scoprirlo. +2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da + `team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato + a mano in `csi-core.ts`. +3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra + istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella + Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`). +4. **I risultati includono anche la Coppa**, non solo il girone di campionato. + +--- + +## Evoluzioni possibili + +- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili). +- Creazione automatica degli eventi partita da calendario CSI. +- Confronto tra i parziali ufficiali e quelli dello Scout Live. diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md new file mode 100644 index 0000000..43fab85 --- /dev/null +++ b/docs/modules/profilo-giocatore.md @@ -0,0 +1,221 @@ +# Modulo — Profilo Giocatore + +## Obiettivo + +Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra. + +L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel. + +## Utenti + +### Giocatore + +Può: + +- visualizzare il proprio profilo +- modificare i propri dati personali +- aggiornare il certificato medico +- aggiornare i documenti +- caricare le immagini richieste + +### Amministratore + +Può: + +- visualizzare il profilo di tutti i giocatori +- scaricare documenti e certificati +- esportare i dati necessari al tesseramento CSI +- verificare lo stato di completamento dei profili +- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo) +- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017) +- scollegare un account da un profilo, liberando lo slot + +Non può caricare o sostituire i file altrui: documento, certificato e foto restano +responsabilità del giocatore che li fornisce. + +## Flusso utente + +### Primo accesso + +1. Login tramite Google oppure Email. *Implementato con il solo Google: la squadra ha tutti + un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa + schermata) il giorno che serve.* +2. Selezione del proprio giocatore. +3. Accesso alla Home. + +Se il profilo non è completo compare automaticamente un widget di completamento. + +## Home + +Il giocatore visualizza un widget dedicato. + +### Completa il tuo profilo + +Viene mostrata una barra di avanzamento (esempio: *Profilo completato — 85%*), composta dalle seguenti sezioni. + +- Dati personali +- Documento di identità +- Certificato medico +- Foto tessera + +Quando tutte le sezioni sono complete il widget scompare automaticamente. + +## Profilo + +Il profilo viene suddiviso in cinque aree. + +### Dati Giocatore + +**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori. + +- Nome +- Cognome +- Numero di maglia +- Ruolo + +**Dati personali** — modificabili dal giocatore. + +- Data di nascita +- Luogo di nascita +- Indirizzo di residenza +- Telefono +- Email + +### Documento di identità + +Campi. + +- Tipo documento +- Numero documento +- Rilasciato da +- Data emissione +- Data scadenza + +Upload. + +- Foto fronte +- Foto retro + +### Certificato medico + +Campi. + +- Data di scadenza + +Upload. + +- Certificato medico + +Il giocatore può aggiornare liberamente sia la data sia il file. + +Lo storico non viene mantenuto nella prima versione. + +### Foto tessera + +Upload di una fotografia formato tessera. + +Utilizzata dagli amministratori per il tesseramento CSI. + +### Statistiche + +Sezione già presente. Contiene. + +- Presenze +- Voto medio +- MVP +- Serie +- Altre statistiche disponibili + +### Badge + +Sezione già presente. + +Contiene tutti i badge ottenuti e quelli ancora da sbloccare. + +### Impostazioni + +Contiene. + +- Logout +- Preferenze notifiche +- Impostazioni applicazione + +## Dashboard amministratore + +Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da +Profilo → Impostazioni). + +Per ogni giocatore vengono mostrati. + +- Stato del profilo +- Certificato medico +- Documento di identità +- Foto tessera + +Azioni disponibili. + +- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata) +- Scarica certificato +- Scarica documento +- Scarica foto tessera +- Modifica dati squadra e dati personali del giocatore (DD-017) +- Scollega account, per liberare uno slot assegnato per errore + +## Esportazione CSI + +Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento. + +Campi esportati. + +- Nome +- Cognome +- Data di nascita +- Luogo di nascita +- Indirizzo +- Telefono +- Email +- Tipo documento +- Numero documento +- Rilasciato da +- Data emissione +- Data scadenza + +## Completamento profilo + +Ogni sezione contribuisce alla percentuale di completamento. + +| Sezione | Peso | +|---|---| +| Dati personali | 30% | +| Documento di identità | 30% | +| Certificato medico | 30% | +| Foto tessera | 10% | + +Quando tutte le sezioni risultano complete il profilo raggiunge il 100%. + +## Permessi + +**Giocatore** — può modificare esclusivamente il proprio profilo. + +**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i +dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017). +Non carica file al posto di altri. + +## Versione 1 + +- Profilo giocatore +- Completamento profilo +- Gestione dati personali +- Documento di identità +- Certificato medico +- Foto tessera +- Dashboard amministratore +- Esportazione CSV CSI + +## Versioni future + +- Storico certificati medici +- Gestione documenti aggiuntivi +- Consensi privacy +- Firma digitale +- Verifica automatica documenti diff --git a/mem/features/cloud-efficienza.md b/mem/features/cloud-efficienza.md deleted file mode 100644 index a33330d..0000000 --- a/mem/features/cloud-efficienza.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Efficienza Cloud -description: Regole per minimizzare query, traffico e invocazioni Cloud (piano 20 crediti/mese, 17 utenti) -type: feature ---- -- Nessun polling (`refetchInterval`) verso il database; sincronizzazione locale via BroadcastChannel/storage dove possibile. -- QueryClient globale: staleTime 5 min, gcTime 30 min, refetchOnWindowFocus/Mount/Reconnect disattivati, retry 1. -- Dopo una mutazione aggiornare la cache con `setQueryData`, non `invalidateQueries` (evita riletture). -- Scout live: scrive solo l'utente che segna; gli altri leggono dati già salvati. -- Statistiche, badge e classifiche: "write once, read many" — calcolate e salvate una volta a fine partita, mai ricalcolate a ogni apertura pagina. -- Dati CSI: sincronizzazione periodica server-side salvata su tabella locale; l'app legge solo dal database interno. -- Push solo per eventi importanti: convocazioni, promemoria allenamento/partita, turno palloni, esito finale. -- Niente foto/video/chat o funzionalità pesanti. -- Schema target: team_id, eventi, presenze, azioni_scout, statistiche_aggregate, classifica_csi, notifiche, con indici sui campi di filtro/relazione. diff --git a/mem/features/portabilita.md b/mem/features/portabilita.md deleted file mode 100644 index 6819684..0000000 --- a/mem/features/portabilita.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: Portabilità su Node.js + PostgreSQL -description: Vincolo di architettura — l'app deve girare su un normale server Node.js con PostgreSQL, senza servizi esclusivi Lovable Cloud -type: constraint ---- -L'app deve restare completamente portabile: ogni funzionalità deve poter girare su un normale server Node.js con PostgreSQL. - -Regole: -- Accesso ai dati solo tramite i moduli in `src/lib/*.ts`; i componenti non parlano mai direttamente col database. -- Vietato usare funzionalità proprietarie Lovable/Supabase non self-hostable (edge functions proprietarie, auth Lovable come unico login, storage proprietario). `src/integrations/lovable/*` resta opzionale e non importato. -- SQL standard PostgreSQL nelle migrazioni; niente estensioni esclusive del provider. -- Configurazione solo via variabili d'ambiente standard; niente valori hardcoded. -- Job pianificati sempre richiamabili con un semplice HTTP POST, così funzionano con qualsiasi scheduler. -- Web push implementato con Web Crypto (compatibile Node 18+), non con SDK proprietari. - -Dettaglio e guida di migrazione: `docs/PORTABILITA.md`. diff --git a/mem/index.md b/mem/index.md index 9def87e..ce78dd3 100644 --- a/mem/index.md +++ b/mem/index.md @@ -1,2 +1,4 @@ -- [Efficienza Cloud](mem://features/cloud-efficienza) — Regole anti-consumo: niente polling, cache lunga, aggregati precalcolati, sync CSI server-side -- [Portabilità](mem://features/portabilita) — L'app deve girare su Node.js + PostgreSQL standard, nessun servizio esclusivo Lovable Cloud +Le regole di progetto non vivono più qui: sono in `docs/`. + +- Efficienza cloud → `docs/EFFICIENZA_CLOUD.md` +- Portabilità → `docs/PORTABILITA.md` diff --git a/package.json b/package.json index c328091..3853f77 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "build:dev": "vite build --mode development", "preview": "vite preview", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "bun test/run.ts", + "test:integration": "bun test/run.ts integration", + "test:e2e": "bun test/run.ts e2e", + "test:all": "bun test/run.ts all" }, "dependencies": { "@hookform/resolvers": "^5.2.2", diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx new file mode 100644 index 0000000..ee88faf --- /dev/null +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -0,0 +1,417 @@ +import { useRef, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Check, Eye, Loader2, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Section } from "@/components/crapp/ui-bits"; +import { Reveal } from "@/components/motion/Reveal"; +import { + caricaFile, + scaricaFile, + useProfili, + useSalvaProfilo, + type SezioneFile, +} from "@/lib/profili"; +import { + completamento, + profiloVuoto, + sezioniComplete, + type Profilo, + type Sezione, +} from "@/lib/profili-core"; + +const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"]; + +const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; + +function Campo({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { + return ( +
+

{titolo}

+ {completa ? : null} +
+ ); +} + +function CampoFile({ + label, + path, + sezione, + giocatoreId, + onCaricato, +}: { + label: string; + path: string | null; + sezione: SezioneFile; + giocatoreId: string; + onCaricato: (path: string) => Promise; +}) { + const input = useRef(null); + const [inCorso, setInCorso] = useState(false); + + async function scegli(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setInCorso(true); + try { + const nuovo = await caricaFile(giocatoreId, sezione, file, path); + await onCaricato(nuovo); + toast.success(`${label} caricato`); + } catch (errore) { + toast.error(errore instanceof Error ? errore.message : "Caricamento non riuscito"); + } finally { + setInCorso(false); + } + } + + return ( +
+ + + {path ? : } + + {label} + + + {path ? ( + + ) : null} + + + +
+ ); +} + +/** + * I campi del profilo, condivisi tra il giocatore e la dashboard amministratore (DD-017). + * Gli upload arrivano come slot: l'admin non carica file al posto di altri, quindi da lì + * quelle righe semplicemente non compaiono. + */ +export function CampiProfilo({ + corrente, + aggiorna, + sezioni, + fileDocumento, + fileCertificato, + fileFoto, +}: { + corrente: Profilo; + aggiorna: (patch: Partial) => void; + sezioni: Record; + fileDocumento?: React.ReactNode; + fileCertificato?: React.ReactNode; + fileFoto?: React.ReactNode; +}) { + return ( + <> + +
+ + aggiorna({ dataNascita: e.target.value })} + className={classiInput} + /> + + + aggiorna({ luogoNascita: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ indirizzo: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ telefono: e.target.value })} + className={classiInput} + /> + + + aggiorna({ email: e.target.value })} + className={classiInput} + /> + +
+ + +
+ + + + + aggiorna({ documentoNumero: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ documentoRilasciatoDa: e.target.value })} + className={classiInput} + /> + +
+ + aggiorna({ documentoEmissione: e.target.value })} + className={classiInput} + /> + + + aggiorna({ documentoScadenza: e.target.value })} + className={classiInput} + /> + +
+ {fileDocumento} + + + + aggiorna({ certificatoScadenza: e.target.value })} + className={classiInput} + /> + + {fileCertificato} + {fileFoto} + + ); +} + +/** + * Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge. + * Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente. + */ +export function ProfiloAmministrativo({ + giocatoreId, + indice = 0, +}: { + giocatoreId: string; + indice?: number; +}) { + const { profili } = useProfili(); + const salva = useSalvaProfilo(); + const [bozza, setBozza] = useState(null); + + const salvato = profili[giocatoreId]; + const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId); + const sporco = bozza !== null; + const perc = completamento(corrente); + const sezioni = sezioniComplete(corrente); + + function aggiorna(patch: Partial) { + setBozza({ ...corrente, ...patch }); + } + + async function scrivi(profilo: Profilo) { + await salva.mutateAsync(profilo); + setBozza(null); + } + + async function salvaBozza() { + try { + await scrivi(corrente); + toast.success("Profilo aggiornato"); + } catch (errore) { + toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito"); + } + } + + // Un file caricato va persistito subito, insieme a quello che si stava scrivendo. + const caricato = (campo: keyof Profilo) => async (path: string) => + scrivi({ ...corrente, [campo]: path }); + + return ( +
{perc}%} + > +
+
+
+
+ +

+ Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. +

+ + + + +
+ } + fileCertificato={ + + } + fileFoto={ + <> + + + + } + /> + + +
+
+ ); +} + +/** + * Widget di Home: sparisce da solo quando il profilo è completo + * (docs/modules/profilo-giocatore.md § Home). + */ +export function CompletaProfilo({ + giocatoreId, + indice = 0, +}: { + giocatoreId: string; + indice?: number; +}) { + const { profili, isPending } = useProfili(); + const perc = completamento(profili[giocatoreId]); + if (isPending || perc === 100) return null; + + return ( + + +
+ + Completa il tuo profilo + + {perc}% +
+
+
+
+

+ Documento, certificato medico e foto tessera servono per il tesseramento CSI. +

+ + + ); +} diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index 8836039..e0df50a 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -4,9 +4,10 @@ import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/crapp/Avatar"; import { Barra } from "@/components/motion/Barra"; -import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data"; +import { giocatori, statoMeta, type Stato } from "@/lib/crapp-data"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"]; @@ -14,6 +15,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) { const { risposte, isPending } = usePresenzeEvento(eventoId); const salva = useSalvaPresenza(); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const [sollecito, setSollecito] = useState(false); const mancanti = giocatori.filter((g) => !risposte[g.id]); @@ -105,7 +107,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
) : null} - {io && isAdmin(io.id) ? ( + {admin ? ( + + setBozza({ ...profiloCorrente, ...patch })} + sezioni={sezioniComplete(profiloCorrente)} + /> + + +
+ + {g.authUserId ? "Account collegato" : "Nessun account collegato"} + + {g.authUserId ? ( + + ) : null} +
+ + ); +} + +function Documento({ + icona, + label, + stato, + path, +}: { + icona: React.ReactNode; + label: string; + stato: StatoScadenza | "presente" | "assente"; + path: string | null; +}) { + const [inCorso, setInCorso] = useState(false); + + async function scarica() { + if (!path || inCorso) return; + setInCorso(true); + try { + await scaricaFile(path); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Download non riuscito"); + } finally { + setInCorso(false); + } + } + + return ( + + ); +} + +function SchedaGiocatore({ + g, + profilo, + oggi, + indice, +}: { + g: GiocatoreSquadra; + profilo: Profilo | undefined; + oggi: string; + indice: number; +}) { + const [aperta, setAperta] = useState(false); + const nome = nomeCompleto(g); + const { ruolo, numero } = g; + const perc = completamento(profilo); + const sezioni = sezioniComplete(profilo); + const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi); + const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi); + const retro = statoScadenza(profilo?.documentoScadenza, profilo?.documentoRetroPath, oggi); + + return ( + + + +
+
+
+ +
+ } + label="Doc fronte" + stato={fronte} + path={profilo?.documentoFrontePath ?? null} + /> + } + label="Doc retro" + stato={retro} + path={profilo?.documentoRetroPath ?? null} + /> + } + label="Certificato" + stato={certificato} + path={profilo?.certificatoPath ?? null} + /> + } + label="Foto" + stato={sezioni.foto ? "presente" : "assente"} + path={profilo?.fotoPath ?? null} + /> +
+ + {aperta ? : null} + + ); +} + +function Dashboard() { + const admin = useIsAdmin(); + const { righe: squadra } = useGiocatoriSquadra(); + const { profili, isPending } = useProfili(); + const oggi = oggiISO(); + + if (!admin) { + return ( + <> + +
+

+ + Riservata agli amministratori della squadra. +

+
+ + ); + } + + const attivi = squadra.filter((g) => g.attivo); + const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length; + const certificatiOk = attivi.filter( + (g) => + statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) === + "valido", + ).length; + + return ( + <> + + +
+
+ + + +
+ +
+ +
+ {isPending ? ( +

+ Caricamento… +

+ ) : ( +
+ {attivi.map((g, i) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/src/routes/api/public/csi.ts b/src/routes/api/public/csi.ts new file mode 100644 index 0000000..c2751de --- /dev/null +++ b/src/routes/api/public/csi.ts @@ -0,0 +1,56 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + CSI_GIRONE, + parseClassifica, + partiteDaEventi, + urlClassifica, + urlPartite, + type DatiCsi, +} from "@/lib/csi-core"; + +const SCADENZA_MS = 6 * 60 * 60 * 1000; + +// ponytail: cache in memoria del processo, si perde ai cold start e non è +// condivisa tra istanze. Basta per una squadra; se il portale CSI diventa +// lento o le richieste crescono, spostare i dati in una tabella Supabase +// riempita da un job cron (stesso pattern di promemoria-palloni). +let cache: DatiCsi | undefined; +let scadenza = 0; + +async function scarica(url: string): Promise { + const res = await fetch(url, { + headers: { "User-Agent": "CrAPP/1.0 (+https://crapvolley.it)" }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`CSI ${res.status} su ${url}`); + return res.text(); +} + +async function leggiCsi(): Promise { + const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]); + const classifica = parseClassifica(html); + const partite = partiteDaEventi(JSON.parse(json)); + if (classifica.length === 0 && partite.length === 0) { + throw new Error("CSI: risposta senza classifica né partite"); + } + return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() }; +} + +export const Route = createFileRoute("/api/public/csi")({ + server: { + handlers: { + GET: async () => { + if (cache && Date.now() < scadenza) return Response.json(cache); + try { + cache = await leggiCsi(); + scadenza = Date.now() + SCADENZA_MS; + } catch (error) { + console.error("csi", error); + // Meglio un dato vecchio che nessun dato: il portale CSI cambia raramente. + if (!cache) return new Response("CSI non raggiungibile", { status: 503 }); + } + return Response.json(cache); + }, + }, + }, +}); diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index 1140b9b..aaab088 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -1,66 +1,154 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { LogIn } from "lucide-react"; +import { toast } from "sonner"; import { TeamLogo } from "@/components/crapp/ui-bits"; -import { giocatori } from "@/lib/crapp-data"; -import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; +import { accediConGoogle, useSessione } from "@/lib/auth"; +import { + nomeCompleto, + slotDi, + slotLiberi, + useCollegaGiocatore, + useGiocatoriSquadra, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; +import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; export const Route = createFileRoute("/benvenuto")({ head: () => ({ meta: [ - { title: "Benvenuto — CrAPP" }, + { title: "Benvenuto — CrAPP DEVELOP" }, { name: "description", - content: "Seleziona il tuo profilo giocatore per iniziare.", + content: "Accedi e collega il tuo profilo giocatore per iniziare.", }, - { property: "og:title", content: "Benvenuto — CrAPP" }, + { property: "og:title", content: "Benvenuto — CrAPP DEVELOP" }, { property: "og:description", - content: "Seleziona il tuo profilo giocatore per iniziare.", + content: "Accedi e collega il tuo profilo giocatore per iniziare.", }, ], }), component: Benvenuto, }); +function Scheda({ + titolo, + sottotitolo, + onClick, + iniziali, +}: { + titolo: string; + sottotitolo: string; + onClick: () => void; + iniziali: string; +}) { + return ( + + ); +} + function Benvenuto() { const navigate = useNavigate(); const giocatore = useGiocatoreCorrente(); + const { pronta, utenteId } = useSessione(); + const { righe, daDatabase } = useGiocatoriSquadra(); + const collega = useCollegaGiocatore(); + const [inCorso, setInCorso] = useState(false); + + const mioSlot = slotDi(righe, utenteId); + // Si entra solo da loggati e con uno slot collegato (DD-011). + const puoEntrare = !!giocatore && !!utenteId; useEffect(() => { - if (giocatore) { - navigate({ to: "/" }); + if (puoEntrare) navigate({ to: "/" }); + }, [puoEntrare, navigate]); + + // Chi sei lo dice lo slot collegato all'account, non quello che c'è in localStorage: + // senza slot la scelta salvata dalla vecchia selezione libera va buttata. + useEffect(() => { + if (mioSlot) impostaGiocatore(mioSlot.id); + else if (utenteId && daDatabase) resetGiocatore(); + }, [mioSlot, utenteId, daDatabase]); + + async function accedi() { + setInCorso(true); + try { + await accediConGoogle(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Accesso non riuscito"); + setInCorso(false); } - }, [giocatore, navigate]); + } + + async function reclama(g: GiocatoreSquadra) { + if (!utenteId) return; + try { + await collega.mutateAsync({ giocatoreId: g.id, utenteId }); + impostaGiocatore(g.id); + } catch { + toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore."); + } + } + + const liberi = slotLiberi(righe); return (

- Benvenuto in CrAPP TEST + Benvenuto in CrAPP DEVELOP

-

- Seleziona chi sei per personalizzare l'app. -

-
- {giocatori.map((g) => ( + + {!pronta ? null : !utenteId ? ( + <> +

+ Accedi con il tuo account Google per collegare il profilo giocatore. +

- ))} -
+ + ) : ( + <> +

+ Sei entrato. Scegli il tuo nome: resterà collegato a questo account. +

+
+ {liberi.map((g) => ( + void reclama(g)} + /> + ))} + {liberi.length === 0 ? ( +

+ Nessun profilo libero: chiedi a un amministratore di collegarti. +

+ ) : null} +
+ + )}
); } diff --git a/src/routes/calendario.tsx b/src/routes/calendario.tsx index e25c52f..3a42a84 100644 --- a/src/routes/calendario.tsx +++ b/src/routes/calendario.tsx @@ -5,9 +5,9 @@ import { Link } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; -import { isAdmin } from "@/lib/crapp-data"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; import { Drawer, DrawerClose, @@ -97,6 +97,7 @@ function Calendario() { const [giornoSelezionato, setGiornoSelezionato] = useState(null); const [drawerAperto, setDrawerAperto] = useState(false); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const { eventi } = useEventi(); const { anno, mese, precedente, successivo } = useMeseNav(); const { giorni, offsetLunedi } = giorniDelMese(anno, mese); @@ -245,7 +246,7 @@ function Calendario() { ) : null} - {io && isAdmin(io.id) ? ( + {admin ? (
({ - id: m.id, - avversario: m.avversario, - casa: m.casa, - setNostri: m.setNostri, - setLoro: m.setLoro, - })), - ...storicoMatch, - ]; + const { data: csi } = useCsi(); + + const classifica = csi?.classifica.length ? csi.classifica : classificaConScout(scoutMatches); + const risultati = csi?.partite.length + ? partiteGiocate(csi.partite).map((p) => ({ + id: p.id, + avversario: p.avversario, + casa: p.casa, + setNostri: p.setNostri ?? 0, + setLoro: p.setLoro ?? 0, + })) + : [ + ...scoutMatches.map((m) => ({ + id: m.id, + avversario: m.avversario, + casa: m.casa, + setNostri: m.setNostri, + setLoro: m.setLoro, + })), + ...storicoMatch, + ]; + return ( <> - +
- Dati CSI aggiornati oggi alle 08:40 (demo) + {csi + ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` + : "Dati CSI in arrivo"}
@@ -58,7 +83,7 @@ function Classifica() { Pt
{classifica.map((r) => { - const noi = r.squadra === "CRAP Volley"; + const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; return (
-
+
{risultati.map((m) => (
); -} \ No newline at end of file +} diff --git a/src/routes/eventi.tsx b/src/routes/eventi.tsx index d85e406..58f152e 100644 --- a/src/routes/eventi.tsx +++ b/src/routes/eventi.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; -import { formatData, giocatori, isAdmin } from "@/lib/crapp-data"; +import { formatData, giocatori } from "@/lib/crapp-data"; import { categoriaEvento, daCategoria, @@ -16,6 +16,7 @@ import { type Evento, } from "@/lib/eventi"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; export const Route = createFileRoute("/eventi")({ head: () => ({ @@ -47,12 +48,13 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [ function GestioneEventi() { const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const { eventi, isPending } = useEventi(); const salva = useSalvaEvento(); const elimina = useEliminaEvento(); const [bozza, setBozza] = useState(null); - if (!io || !isAdmin(io.id)) { + if (!io || !admin) { return ( <> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ec8a27c..544b6cd 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,6 +4,7 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni"; import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; +import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { Reveal } from "@/components/motion/Reveal"; import { Barra } from "@/components/motion/Barra"; import { Numero } from "@/components/motion/Numero"; @@ -24,7 +25,8 @@ export const Route = createFileRoute("/")({ { property: "og:title", content: "CrAPP — L'app del CRAP Volley" }, { property: "og:description", - content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.", + content: + "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.", }, ], }), @@ -105,6 +107,8 @@ function Index() {
+ +
{prossimi.slice(1).map((e) => { @@ -155,25 +159,29 @@ function Index() { titolo="Obiettivo di squadra" indice={4} azione={ - + Tutti } > {obiettivo ? ( -
-
- {obiettivo.emoji} {obiettivo.titolo} +
+
+ {obiettivo.emoji} {obiettivo.titolo} +
+ +

+ Siamo al —{" "} + {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. +

+

+ {microcopyObiettivo(obiettivo)} +

+

{obiettivo.impatto}

- -

- Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target}{" "} - {obiettivo.unita}. -

-

{microcopyObiettivo(obiettivo)}

-

{obiettivo.impatto}

-
) : null}
diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 57b258d..2dc6574 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -2,13 +2,14 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; -import { storicoMatch, formatData, giocatori, isAdmin } from "@/lib/crapp-data"; +import { storicoMatch, formatData, giocatori } from "@/lib/crapp-data"; import { convocatiEvento, useEvento } from "@/lib/eventi"; import { Pagelle } from "@/components/crapp/Pagelle"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotoSocial } from "@/components/crapp/VotoSocial"; import { TurnoPalloni } from "@/components/crapp/TurnoPalloni"; @@ -36,6 +37,7 @@ function PartitaDetail() { const { id } = Route.useParams(); const { evento } = useEvento(id); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const scoutMatches = useScoutMatches(); const { risposte } = usePresenzeEvento(id); const presentiVeri = giocatori.filter( @@ -224,7 +226,7 @@ function PartitaDetail() { ); })}
- {io && isAdmin(io.id) ? ( + {admin ? (
diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 6aaeda0..f34ac4e 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -3,9 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { giocatori, formatData, isAdmin } from "@/lib/crapp-data"; +import { giocatori, formatData } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; import { useGiocatoreCorrente } from "@/lib/user-store"; +import { useIsAdmin } from "@/lib/ruoli"; import { usePresenzeEvento } from "@/lib/presenze"; import { statoIniziale, @@ -70,6 +71,7 @@ function Blocco({ icona, titolo, testo, children }: { icona: React.ReactNode; ti function Scout() { const { pronto, partita } = usePartitaDiOggi(); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const sessione = useSessioneScout(partita?.id ?? null); const statoSalvato = useStatoScout(partita?.id ?? null); const apri = useApriSessioneScout(); @@ -88,7 +90,7 @@ function Scout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [controllo, partita?.id, io?.id]); - if (io && !isAdmin(io.id)) { + if (io && !admin) { return ( } diff --git a/supabase/.branches/_current_branch b/supabase/.branches/_current_branch new file mode 100644 index 0000000..88d050b --- /dev/null +++ b/supabase/.branches/_current_branch @@ -0,0 +1 @@ +main \ No newline at end of file diff --git a/supabase/config.toml b/supabase/config.toml index c87d4c8..30be195 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1,22 @@ -project_id = "hetycilxgkdmccelwerq" \ No newline at end of file +project_id = "kfkcldwncxqaixetsjes" + +# Configurazione dell'istanza locale (`supabase start`). Non tocca il progetto cloud: +# lì le stesse impostazioni si mettono dalla dashboard Supabase. + +[auth] +site_url = "http://localhost:8080" +additional_redirect_urls = ["http://localhost:8080"] + +# Accesso via email con Mailpit (http://127.0.0.1:54324) per le prove locali: +# nessuna mail esce dalla macchina e non serve confermare l'indirizzo. +[auth.email] +enable_signup = true +enable_confirmations = false + +# Google in locale: metti client id e secret in `.env` e porta `enabled` a true. +# Il redirect da registrare in Google Cloud è http://127.0.0.1:54321/auth/v1/callback, +# che convive con quello di produzione sulla stessa credenziale. +[auth.external.google] +enabled = false +client_id = "env(SUPABASE_AUTH_GOOGLE_CLIENT_ID)" +secret = "env(SUPABASE_AUTH_GOOGLE_SECRET)" diff --git a/supabase/migrations/20260828170400_m1_giocatori_squadra.sql b/supabase/migrations/20260828170400_m1_giocatori_squadra.sql new file mode 100644 index 0000000..f37d510 --- /dev/null +++ b/supabase/migrations/20260828170400_m1_giocatori_squadra.sql @@ -0,0 +1,106 @@ +-- M1 — Anagrafica operativa squadra (Profilo Giocatore v1.1, DD-016) +-- Tabella additive: non modifica alcuna tabella v1.0 esistente. + +CREATE TABLE public.giocatori_squadra ( + id text PRIMARY KEY, + nome text NOT NULL, + cognome text NOT NULL, + numero integer NOT NULL CHECK (numero > 0), + ruolo text NOT NULL, + auth_user_id uuid UNIQUE REFERENCES auth.users(id) ON DELETE SET NULL, + attivo boolean NOT NULL DEFAULT true, + creato_il timestamptz NOT NULL DEFAULT now(), + aggiornato_il timestamptz NOT NULL DEFAULT now(), + CONSTRAINT giocatori_squadra_id_formato CHECK (id ~ '^g[0-9]+$') +); + +COMMENT ON TABLE public.giocatori_squadra IS + 'Anagrafica operativa della squadra (ID testuali g1..gN). Source of truth progressiva per la rosa.'; + +-- Enforce DD-016: il giocatore può solo reclamare uno slot libero (auth_user_id NULL → auth.uid()), +-- senza modificare altri campi. Gli admin possono aggiornare tutto, incluso il reset di auth_user_id. +CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN + RETURN NEW; + END IF; + + IF OLD.auth_user_id IS NULL + AND NEW.auth_user_id = auth.uid() + AND NEW.id IS NOT DISTINCT FROM OLD.id + AND NEW.nome IS NOT DISTINCT FROM OLD.nome + AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome + AND NEW.numero IS NOT DISTINCT FROM OLD.numero + AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo + AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo + AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra'; +END; +$$; + +CREATE TRIGGER enforce_giocatori_squadra_update + BEFORE UPDATE ON public.giocatori_squadra + FOR EACH ROW + EXECUTE FUNCTION public.enforce_giocatori_squadra_update(); + +CREATE TRIGGER update_giocatori_squadra_aggiornato_il + BEFORE UPDATE ON public.giocatori_squadra + FOR EACH ROW + EXECUTE FUNCTION public.update_aggiornato_il(); + +GRANT SELECT, UPDATE ON public.giocatori_squadra TO authenticated; +GRANT ALL ON public.giocatori_squadra TO service_role; + +ALTER TABLE public.giocatori_squadra ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Authenticated can view active roster" + ON public.giocatori_squadra + FOR SELECT + TO authenticated + USING ( + attivo = true + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ); + +CREATE POLICY "Users can claim unassigned roster slot" + ON public.giocatori_squadra + FOR UPDATE + TO authenticated + USING (auth_user_id IS NULL) + WITH CHECK (auth_user_id = auth.uid()); + +CREATE POLICY "Admins can manage roster" + ON public.giocatori_squadra + FOR ALL + TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- Seed da src/lib/crapp-data.ts (rosaCSI, ordine g1..g17). +-- Nome/cognome: primo token = nome, resto = cognome (es. "Carlo Di Castelnuovo" → Carlo / Di Castelnuovo). +INSERT INTO public.giocatori_squadra (id, nome, cognome, numero, ruolo) VALUES + ('g1', 'Salvador', 'Battistella', 88, 'Libero'), + ('g2', 'Mattias', 'Bologna', 73, 'Centrale'), + ('g3', 'Alessandra', 'Brunacci', 8, 'Palleggiatore'), + ('g4', 'Ivan', 'Cacciari', 23, 'Banda'), + ('g5', 'Mattia', 'Catalano', 21, 'Palleggiatore'), + ('g6', 'Silvia', 'Chilese', 11, 'Libero'), + ('g7', 'Alessio', 'Cocco', 77, 'Centrale'), + ('g8', 'Carlo', 'Di Castelnuovo', 14, 'Opposto'), + ('g9', 'Camilla', 'Esposito', 7, 'Palleggiatore'), + ('g10', 'Davide', 'Grilli', 1, 'Opposto'), + ('g11', 'Antonella', 'Loverre', 22, 'Banda'), + ('g12', 'Laura', 'Passabì', 5, 'Banda'), + ('g13', 'Nicola', 'Pezzoli', 4, 'Centrale'), + ('g14', 'Iacopo', 'Ricci', 2, 'Banda'), + ('g15', 'Cristina', 'Titone', 3, 'Libero'), + ('g16', 'Francesca', 'Tucci', 18, 'Centrale'), + ('g17', 'Giada', 'Valbonesi', 10, 'Opposto'); diff --git a/supabase/migrations/20260830123000_m2_profili_giocatore.sql b/supabase/migrations/20260830123000_m2_profili_giocatore.sql new file mode 100644 index 0000000..2ddc1f5 --- /dev/null +++ b/supabase/migrations/20260830123000_m2_profili_giocatore.sql @@ -0,0 +1,185 @@ +-- M2 — Profilo giocatore + Storage privato (DD-016) +-- Tabella e bucket additive: non modifica alcuna tabella v1.0 esistente. + +CREATE TYPE public.doc_identita_tipo AS ENUM ( + 'carta_identita', + 'passaporto', + 'patente' +); + +CREATE OR REPLACE FUNCTION public.mio_giocatore_id() +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT id + FROM public.giocatori_squadra + WHERE auth_user_id = auth.uid() + LIMIT 1; +$$; + +REVOKE ALL ON FUNCTION public.mio_giocatore_id() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.mio_giocatore_id() TO authenticated; + +CREATE TABLE public.profili_giocatore ( + giocatore_id text PRIMARY KEY + REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE, + data_nascita date, + luogo_nascita text, + indirizzo text, + telefono text, + email text, + doc_tipo public.doc_identita_tipo, + doc_numero text, + doc_rilasciato_da text, + doc_data_emissione date, + doc_data_scadenza date, + doc_fronte_path text, + doc_retro_path text, + cert_scadenza date, + cert_file_path text, + foto_tessera_path text, + avatar_path text, + creato_il timestamptz NOT NULL DEFAULT now(), + aggiornato_il timestamptz NOT NULL DEFAULT now(), + CONSTRAINT profilo_date_doc_valide CHECK ( + doc_data_emissione IS NULL + OR doc_data_scadenza IS NULL + OR doc_data_emissione <= doc_data_scadenza + ) +); + +COMMENT ON TABLE public.profili_giocatore IS + 'Dati personali e path documenti. File binari nel bucket privato profili-giocatore.'; + +CREATE OR REPLACE FUNCTION public.enforce_profili_giocatore_pk() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NEW.giocatore_id IS DISTINCT FROM OLD.giocatore_id THEN + RAISE EXCEPTION 'giocatore_id non modificabile'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_profili_giocatore_pk + BEFORE UPDATE ON public.profili_giocatore + FOR EACH ROW + EXECUTE FUNCTION public.enforce_profili_giocatore_pk(); + +CREATE TRIGGER update_profili_giocatore_aggiornato_il + BEFORE UPDATE ON public.profili_giocatore + FOR EACH ROW + EXECUTE FUNCTION public.update_aggiornato_il(); + +GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated; +GRANT ALL ON public.profili_giocatore TO service_role; + +ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Players can view own profile" + ON public.profili_giocatore + FOR SELECT + TO authenticated + USING ( + giocatore_id = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ); + +CREATE POLICY "Players can insert own profile" + ON public.profili_giocatore + FOR INSERT + TO authenticated + WITH CHECK ( + giocatore_id = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ); + +CREATE POLICY "Players can update own profile" + ON public.profili_giocatore + FOR UPDATE + TO authenticated + USING ( + giocatore_id = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ) + WITH CHECK ( + giocatore_id = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ); + +CREATE POLICY "Admins can delete profiles" + ON public.profili_giocatore + FOR DELETE + TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)); + +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'profili-giocatore', + 'profili-giocatore', + false, + 10485760, + ARRAY['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] +); + +CREATE POLICY "Players can read own profile files" + ON storage.objects + FOR SELECT + TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND ( + (storage.foldername(name))[1] = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ) + ); + +CREATE POLICY "Players can upload own profile files" + ON storage.objects + FOR INSERT + TO authenticated + WITH CHECK ( + bucket_id = 'profili-giocatore' + AND ( + (storage.foldername(name))[1] = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ) + ); + +CREATE POLICY "Players can overwrite own profile files" + ON storage.objects + FOR UPDATE + TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND ( + (storage.foldername(name))[1] = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ) + ) + WITH CHECK ( + bucket_id = 'profili-giocatore' + AND ( + (storage.foldername(name))[1] = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ) + ); + +CREATE POLICY "Players can delete own profile files" + ON storage.objects + FOR DELETE + TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND ( + (storage.foldername(name))[1] = public.mio_giocatore_id() + OR public.has_role(auth.uid(), 'admin'::public.app_role) + ) + ); diff --git a/supabase/migrations/20260831120000_m4_solo_autenticati.sql b/supabase/migrations/20260831120000_m4_solo_autenticati.sql new file mode 100644 index 0000000..6ca74c7 --- /dev/null +++ b/supabase/migrations/20260831120000_m4_solo_autenticati.sql @@ -0,0 +1,19 @@ +-- M4 — Chiusura degli accessi anonimi alle tabelle v1.0 (DD-011). +-- +-- Da applicare SOLO quando tutti hanno collegato l'account Google: da qui in poi il +-- ruolo `anon` non legge né scrive più nulla, quindi chi non ha fatto login vede l'app +-- vuota. Le route in `src/routes/api/public/` usano la service role e non sono toccate. +-- +-- Le policy restano dichiarate `TO anon, authenticated`: senza GRANT il ruolo anon non +-- arriva comunque alla tabella, e le policy continuano a valere per gli autenticati. + +REVOKE ALL ON public.turni_palloni FROM anon; +REVOKE ALL ON public.push_subscriptions FROM anon; +REVOKE ALL ON public.badge_social_voti FROM anon; +REVOKE ALL ON public.scout_sessioni FROM anon; +REVOKE ALL ON public.scout_live FROM anon; +REVOKE ALL ON public.mvp_voti FROM anon; +REVOKE ALL ON public.risposte_presenze FROM anon; +REVOKE ALL ON public.eventi_app FROM anon; +REVOKE ALL ON public.pagelle_voti FROM anon; +REVOKE ALL ON public.cacche_partita FROM anon; diff --git a/supabase/migrations/20260831170000_correzione_schema_profili_giocatore.sql b/supabase/migrations/20260831170000_correzione_schema_profili_giocatore.sql new file mode 100644 index 0000000..ef7f4c9 --- /dev/null +++ b/supabase/migrations/20260831170000_correzione_schema_profili_giocatore.sql @@ -0,0 +1,135 @@ +-- Correzione schema profili_giocatore (post 20260830123000) +-- +-- Prerequisito: la migration 20260830123000 deve essere già applicata (schema doc_*, +-- enum doc_identita_tipo, funzione mio_giocatore_id, policy inglesi). +-- Porta il database allo schema atteso dal codice di develop (20260830120000 + 20100). +-- Non modifica giocatori_squadra né le tabelle v1.0. +-- Supabase avvolge ogni migration in una transazione: non serve BEGIN/COMMIT esplicito +-- (inserirli nel file può interferire con db push). + +-- ── 1. Policy tabella: rimuovi quelle che dipendono da mio_giocatore_id() ─── +DROP POLICY "Players can view own profile" ON public.profili_giocatore; +DROP POLICY "Players can insert own profile" ON public.profili_giocatore; +DROP POLICY "Players can update own profile" ON public.profili_giocatore; + +-- Policy admin separata: va sostituita dal modello Davide (FOR ALL). +DROP POLICY "Admins can delete profiles" ON public.profili_giocatore; + +-- ── 2. Policy Storage: tutte usano mio_giocatore_id() ────────────────────── +DROP POLICY "Players can read own profile files" ON storage.objects; +DROP POLICY "Players can upload own profile files" ON storage.objects; +DROP POLICY "Players can overwrite own profile files" ON storage.objects; +DROP POLICY "Players can delete own profile files" ON storage.objects; + +-- ── 3. Trigger e funzione PK enforcement (assenti nello schema Davide) ───── +DROP TRIGGER enforce_profili_giocatore_pk ON public.profili_giocatore; +DROP FUNCTION public.enforce_profili_giocatore_pk(); + +-- ── 4. Vincolo sulle date documento (prima delle rinomine di colonna) ──────── +ALTER TABLE public.profili_giocatore + DROP CONSTRAINT profilo_date_doc_valide; + +-- ── 5. doc_tipo enum → documento_tipo text (valori UI del codice) ────────── +ALTER TABLE public.profili_giocatore + ALTER COLUMN doc_tipo TYPE text + USING ( + CASE doc_tipo::text + WHEN 'carta_identita' THEN 'Carta d''identità' + WHEN 'patente' THEN 'Patente' + WHEN 'passaporto' THEN 'Passaporto' + ELSE doc_tipo::text + END + ); + +ALTER TABLE public.profili_giocatore + RENAME COLUMN doc_tipo TO documento_tipo; + +-- ── 6. Rinomina colonne allo schema atteso da profili-core.ts ─────────────── +ALTER TABLE public.profili_giocatore RENAME COLUMN doc_numero TO documento_numero; +ALTER TABLE public.profili_giocatore RENAME COLUMN doc_rilasciato_da TO documento_rilasciato_da; +ALTER TABLE public.profili_giocatore RENAME COLUMN doc_data_emissione TO documento_emissione; +ALTER TABLE public.profili_giocatore RENAME COLUMN doc_data_scadenza TO documento_scadenza; +ALTER TABLE public.profili_giocatore RENAME COLUMN doc_fronte_path TO documento_fronte_path; +ALTER TABLE public.profili_giocatore RENAME COLUMN doc_retro_path TO documento_retro_path; +ALTER TABLE public.profili_giocatore RENAME COLUMN cert_scadenza TO certificato_scadenza; +ALTER TABLE public.profili_giocatore RENAME COLUMN cert_file_path TO certificato_path; +ALTER TABLE public.profili_giocatore RENAME COLUMN foto_tessera_path TO foto_path; + +-- ── 7. Colonna non usata dal codice ───────────────────────────────────────── +ALTER TABLE public.profili_giocatore DROP COLUMN avatar_path; + +-- ── 8. Oggetti della migration 123000 non più necessari ───────────────────── +DROP FUNCTION public.mio_giocatore_id(); +DROP TYPE public.doc_identita_tipo; + +COMMENT ON TABLE public.profili_giocatore IS + 'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.'; + +-- ── 9. RLS tabella (modello Davide, DD-017) ───────────────────────────────── +CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore + FOR INSERT TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore + FOR UPDATE TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- ── 10. Policy Storage (DD-017: admin solo lettura) ───────────────────────── +CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects + FOR ALL TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + bucket_id = 'profili-giocatore' + AND EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects + FOR SELECT TO authenticated + USING ( + bucket_id = 'profili-giocatore' + AND public.has_role(auth.uid(), 'admin'::public.app_role) + ); + +-- ── 11. Bucket: resta privato; limiti MIME/dimensione invariati ───────────── +UPDATE storage.buckets +SET public = false +WHERE id = 'profili-giocatore'; diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..0048ecd --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,37 @@ +-- Seed di sviluppo: gira solo in locale (`supabase start` e `supabase db reset`), +-- mai in produzione. Serve a vedere la dashboard amministratore con dati realistici +-- senza inserire righe finte nel database vero. +-- +-- I path dei file puntano a oggetti che nel bucket non esistono: i pulsanti di download +-- falliscono finché non carichi qualcosa dall'app o dallo Studio (http://127.0.0.1:54323). + +INSERT INTO public.profili_giocatore + (giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, + documento_tipo, documento_numero, documento_rilasciato_da, + documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, + certificato_scadenza, certificato_path, foto_path) +VALUES + -- Profilo completo al 100%. + ('g1', '1997-08-30', 'Bologna', 'Via Roma 1', '3330000001', 'g1@example.test', + 'Carta d''identità', 'CA1000001', 'Comune di Bologna', + '2021-03-01', '2031-03-01', 'g1/documento-fronte.jpg', 'g1/documento-retro.jpg', + '2027-06-30', 'g1/certificato.pdf', 'g1/foto.jpg'), + + -- Certificato scaduto: in dashboard deve comparire rosso. + ('g4', '1995-05-01', 'Bologna', 'Via Verdi 2', '3330000004', 'g4@example.test', + 'Carta d''identità', 'CA1000004', 'Comune di Bologna', + '2019-05-01', '2029-05-01', 'g4/documento-fronte.jpg', 'g4/documento-retro.jpg', + '2025-01-01', 'g4/certificato.pdf', 'g4/foto.jpg'), + + -- Profilo a metà: dati personali sì, documento no, certificato sì, foto no. + ('g2', '1996-12-07', 'Modena', 'Via Bianchi 3', '3330000002', 'g2@example.test', + NULL, NULL, NULL, NULL, NULL, NULL, NULL, + '2027-09-15', 'g2/certificato.pdf', NULL) +ON CONFLICT (giocatore_id) DO NOTHING; + +-- Il primo amministratore non si può seminare qui: `user_roles.user_id` punta a un utente +-- di `auth.users`, che su un database appena creato non esiste ancora. Dopo il primo login +-- (in locale come in produzione) basta una riga: +-- +-- INSERT INTO public.user_roles (user_id, role) +-- SELECT id, 'admin' FROM auth.users WHERE email = ''; diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..6f2cffa --- /dev/null +++ b/test/README.md @@ -0,0 +1,51 @@ +# Test + +Suite eseguita con **bun**, senza framework né dipendenze aggiuntive: ogni file è +uno script che usa `node:assert/strict` e termina con codice diverso da zero se +un controllo fallisce. + +```bash +npm run test # unit (veloce, nessuna rete, nessun database) +npm run test:integration # route server vere +npm run test:e2e # percorsi sull'app servita +npm run test:all # tutto +bun test/unit/badges.test.ts # un singolo file +``` + +Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test +non può inquinare gli altri. + +## Struttura + +| Cartella | Cosa verifica | Serve rete? | +|---|---|---| +| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa | No | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato | Sì | +| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | +| `helpers/` | Avvio del server di test e mini-harness condiviso | — | + +## Convenzioni + +- **Nessun test scrive sul database.** Integration ed e2e fanno solo letture e + verifiche di validazione: si possono lanciare anche contro l'ambiente reale. + L'unica eccezione apparente è `schema-profili`, che *tenta* scritture da utente + anonimo proprio per dimostrare che la RLS le respinge, e poi rilegge la riga per + verificare che non sia cambiata: su un UPDATE a zero righe PostgREST risponde 2xx, + quindi lo stato conta più del codice di risposta. +- I test legati alle migration M2/M3 si **saltano da soli** dove quelle migration non + sono ancora applicate, indicandolo nel motivo. Per vederli tutti verdi serve un + database che le contenga: `npx supabase start` ne crea uno in locale. +- `integration` ed `e2e` avviano da soli il server di sviluppo. Per usarne uno già + attivo: `BASE_URL=http://localhost:8080 npm run test:e2e`. +- Le variabili d'ambiente vengono lette da `.env`; i nomi senza prefisso + (`SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`) sono derivati da quelli `VITE_*`. + I test che richiedono `SUPABASE_SERVICE_ROLE_KEY` si saltano da soli se manca. +- Il check del parsing CSI può girare contro il portale reale: + `CSI_LIVE=1 bun test/unit/csi-core.test.ts`. + +## Limite noto + +L'app renderizza i contenuti dopo l'idratazione: il server invia solo il guscio +(titolo, meta, splash). Gli e2e verificano quindi le risposte HTTP, i dati che +alimentano le pagine e i file della PWA, ma **non** l'interfaccia renderizzata. +Per quella servirebbe un driver browser (es. Playwright), oggi non installato. diff --git a/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts new file mode 100644 index 0000000..f2c2331 --- /dev/null +++ b/test/e2e/percorsi.test.ts @@ -0,0 +1,115 @@ +/** + * Percorsi end-to-end sull'app servita davvero: `bun test/e2e/percorsi.test.ts`. + * Verifica il giro completo route → server → dati → risposta HTTP. + * + * L'app renderizza i contenuti dopo l'idratazione: il server invia il guscio + * (titolo, meta, splash) e il resto arriva nel browser. Qui si verifica quindi + * tutto ciò che è osservabile senza browser; per i contenuti renderizzati + * servirebbe un driver tipo Playwright. + */ +import assert from "node:assert/strict"; +import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core"; +import { avviaServer, json } from "../helpers/server"; +import { prova, riepilogo } from "../helpers/prova"; + +const server = await avviaServer(); +const url = (p: string) => `${server.baseUrl}${p}`; + +async function pagina(percorso: string): Promise { + const res = await fetch(url(percorso)); + assert.equal(res.status, 200, `${percorso} deve rispondere 200`); + assert.match(res.headers.get("content-type") ?? "", /text\/html/, `${percorso} è una pagina`); + const html = await res.text(); + assert.doesNotMatch(html, /Errore imprevisto|h3 swallowed/i, `${percorso}: nessun errore server`); + return html; +} + +const titolo = (html: string) => /(.*?)<\/title>/.exec(html)?.[1] ?? ""; + +console.log(`percorsi end-to-end su ${server.baseUrl}`); + +try { + // --- 1. Primo avvio: l'app si carica -------------------------------------- + await prova("la home serve il guscio dell'app con il proprio titolo", async () => { + const html = await pagina("/"); + assert.match(titolo(html), /CrAPP/, "titolo della home"); + assert.match(html, /icon-192\.png/, "splash di caricamento"); + assert.match(html, /manifest\.webmanifest/, "manifest collegato: installabile come PWA"); + assert.match(html, /<meta name="description"/, "descrizione per la condivisione"); + }); + + // --- 2. Ogni schermata ha la sua identità ---------------------------------- + await prova("ogni schermata risponde con il proprio titolo", async () => { + const attesi: Array<[string, RegExp]> = [ + ["/benvenuto", /Benvenuto/], + ["/squadra", /Squadra/], + ["/classifica", /Classifica/], + ["/calendario", /Calendario|CrAPP/], + ["/profilo", /Profilo|CrAPP/], + ["/eventi", /Eventi|CrAPP/], + ["/scout", /Scout|CrAPP/], + ["/admin", /Dashboard|CrAPP/], + ]; + for (const [percorso, atteso] of attesi) { + assert.match(titolo(await pagina(percorso)), atteso, `${percorso}: titolo corretto`); + } + }); + + // --- 3. Campionato: dal portale CSI fino alla pagina ----------------------- + await prova("i dati ufficiali CSI arrivano fino alla pagina Campionato", async () => { + const res = await fetch(url("/api/public/csi")); + assert.equal(res.status, 200, "l'endpoint che alimenta la pagina risponde"); + const csi = (await json(res)) as DatiCsi; + const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra)); + assert.ok(noi, "la nostra squadra arriva dal portale"); + assert.ok( + csi.partite.some((p) => p.setNostri !== null), + "ci sono risultati giocati", + ); + + const html = await pagina("/classifica"); + assert.match(titolo(html), /Classifica campionato/); + assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio"); + }); + + // --- 4. Dettaglio di un evento -------------------------------------------- + await prova("le pagine di dettaglio reggono un id inesistente", async () => { + for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) { + const res = await fetch(url(percorso)); + assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`); + } + }); + + // --- 5. Installazione come PWA e notifiche push --------------------------- + await prova("i file necessari alla PWA sono serviti", async () => { + const manifest = await fetch(url("/manifest.webmanifest")); + assert.equal(manifest.status, 200); + const dati = (await json(manifest)) as { name?: string; icons?: unknown[]; start_url?: string }; + assert.ok(dati.name?.includes("CrAPP")); + assert.equal(dati.start_url, "/"); + assert.ok((dati.icons ?? []).length >= 2, "icone per l'installazione"); + + for (const file of ["/push-sw.js", "/icon-192.png", "/icon-512.png", "/robots.txt"]) { + assert.equal((await fetch(url(file))).status, 200, `${file} raggiungibile`); + } + }); + + await prova("il service worker e la sua configurazione si parlano", async () => { + const sw = await (await fetch(url("/push-sw.js"))).text(); + assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker"); + assert.match(sw, /showNotification/, "il worker mostra la notifica"); + const config = await fetch(url("/api/public/push-config")); + assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile"); + }); + + // --- 6. Percorsi inesistenti ---------------------------------------------- + await prova("una rotta inesistente risponde 404 senza rompere l'app", async () => { + const res = await fetch(url("/pagina-che-non-esiste")); + assert.equal(res.status, 404); + assert.match(res.headers.get("content-type") ?? "", /text\/html/); + }); + + riepilogo("percorsi end-to-end"); +} finally { + server.stop(); +} diff --git a/test/helpers/prova.ts b/test/helpers/prova.ts new file mode 100644 index 0000000..af883dc --- /dev/null +++ b/test/helpers/prova.ts @@ -0,0 +1,32 @@ +/** Mini-harness condiviso: nessuna dipendenza, solo conteggio e uscita non-zero. */ + +const falliti: string[] = []; +let eseguiti = 0; +let saltati = 0; + +export async function prova(nome: string, fn: () => Promise<void> | void) { + eseguiti += 1; + try { + await fn(); + console.log(` ✓ ${nome}`); + } catch (errore) { + falliti.push(nome); + console.error(` ✗ ${nome}\n ${(errore as Error).message.split("\n")[0]}`); + } +} + +export function salta(nome: string, motivo: string) { + saltati += 1; + console.log(` · ${nome} (saltato: ${motivo})`); +} + +/** Da chiamare a fine file: stampa il riepilogo e imposta il codice di uscita. */ +export function riepilogo(gruppo: string) { + const passati = eseguiti - falliti.length; + console.log( + `${gruppo}: ${passati}/${eseguiti} ok${saltati ? `, ${saltati} saltati` : ""}${ + falliti.length ? `, falliti: ${falliti.join(", ")}` : "" + }`, + ); + if (falliti.length) process.exit(1); +} diff --git a/test/helpers/server.ts b/test/helpers/server.ts new file mode 100644 index 0000000..5cfb9cd --- /dev/null +++ b/test/helpers/server.ts @@ -0,0 +1,111 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { readFileSync } from "node:fs"; + +/** + * Avvia il server di sviluppo per i test che parlano HTTP. + * Con BASE_URL impostata usa un server già attivo e non ne avvia uno nuovo. + */ + +export type ServerTest = { baseUrl: string; stop: () => void }; + +/** + * `.env` contiene solo le variabili `VITE_*`, ma le route server leggono i nomi + * senza prefisso (vedi docs/modules/collegamento-csi.md e client.server.ts): + * qui li deriviamo, così i test girano senza configurazione aggiuntiva. + */ +export function envDaFile(): Record<string, string> { + let testo = ""; + try { + testo = readFileSync(new URL("../../.env", import.meta.url), "utf8"); + } catch { + return {}; + } + const env: Record<string, string> = {}; + for (const riga of testo.split("\n")) { + const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(riga); + if (m?.[1]) env[m[1]] = (m[2] ?? "").trim().replace(/^["']|["']$/g, ""); + } + if (!env["SUPABASE_URL"] && env["VITE_SUPABASE_URL"]) + env["SUPABASE_URL"] = env["VITE_SUPABASE_URL"]; + if (!env["SUPABASE_PUBLISHABLE_KEY"] && env["VITE_SUPABASE_PUBLISHABLE_KEY"]) { + env["SUPABASE_PUBLISHABLE_KEY"] = env["VITE_SUPABASE_PUBLISHABLE_KEY"]; + } + return env; +} + +export function haSupabase(): boolean { + const env = { ...envDaFile(), ...process.env }; + return Boolean(env["SUPABASE_URL"] && env["SUPABASE_SERVICE_ROLE_KEY"]); +} + +export async function avviaServer(timeoutMs = 120_000): Promise<ServerTest> { + const esistente = process.env["BASE_URL"]; + if (esistente) return { baseUrl: esistente.replace(/\/$/, ""), stop: () => {} }; + + const processo: ChildProcess = spawn("npm", ["run", "dev"], { + cwd: new URL("../..", import.meta.url).pathname, + env: { ...envDaFile(), ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + // Gruppo di processi dedicato: npm avvia vite come figlio e un SIGTERM al + // solo npm lascerebbe il server orfano ad occupare la porta. + detached: true, + }); + + const stop = () => { + if (processo.killed || processo.pid === undefined) return; + try { + process.kill(-processo.pid, "SIGTERM"); + } catch { + processo.kill("SIGTERM"); + } + }; + + const baseUrl = await new Promise<string>((resolve, reject) => { + const scadenza = setTimeout(() => { + stop(); + reject(new Error(`Il server non è partito entro ${timeoutMs / 1000}s`)); + }, timeoutMs); + + let uscita = ""; + const cerca = (chunk: Buffer) => { + uscita += chunk.toString(); + const url = /http:\/\/localhost:\d+/.exec(uscita)?.[0]; + if (url) { + clearTimeout(scadenza); + resolve(url); + } + }; + processo.stdout?.on("data", cerca); + processo.stderr?.on("data", cerca); + processo.on("exit", (code) => { + clearTimeout(scadenza); + reject(new Error(`Il server è uscito con codice ${code}:\n${uscita}`)); + }); + }); + + await attendiPronto(baseUrl); + return { baseUrl, stop }; +} + +/** Il server annuncia l'URL prima di saper rispondere: attende la prima risposta. */ +async function attendiPronto(baseUrl: string, tentativi = 60) { + for (let i = 0; i < tentativi; i += 1) { + try { + const res = await fetch(baseUrl, { signal: AbortSignal.timeout(5_000) }); + if (res.status < 500) return; + } catch { + /* non ancora pronto */ + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Il server non risponde su ${baseUrl}`); +} + +export async function json(res: Response): Promise<unknown> { + const testo = await res.text(); + try { + return JSON.parse(testo); + } catch { + throw new Error(`Risposta non JSON (${res.status}): ${testo.slice(0, 200)}`); + } +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts new file mode 100644 index 0000000..09b055f --- /dev/null +++ b/test/integration/api.test.ts @@ -0,0 +1,153 @@ +/** + * Integrazione delle route server: `bun test/integration/api.test.ts`. + * Avvia il server di sviluppo (o usa BASE_URL) e chiama gli endpoint veri. + * Nessun test scrive sul database: solo letture e validazioni. + */ +import assert from "node:assert/strict"; +import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core"; +import { avviaServer, haSupabase, json } from "../helpers/server"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const server = await avviaServer(); +const url = (p: string) => `${server.baseUrl}${p}`; +const postJson = (p: string, body: unknown) => + fetch(url(p), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +console.log(`integrazione API su ${server.baseUrl}`); + +try { + // --- GET /api/public/csi --------------------------------------------------- + let csi: DatiCsi | undefined; + + await prova("GET /api/public/csi restituisce classifica e partite", async () => { + const res = await fetch(url("/api/public/csi")); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /application\/json/); + csi = (await json(res)) as DatiCsi; + assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0); + assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0); + assert.equal(csi.girone, "Girone B"); + assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido"); + }); + + await prova("la classifica contiene la nostra squadra con dati coerenti", () => { + const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra)); + assert.ok(noi, "C.R.A.P. Volley presente in classifica"); + assert.equal(noi.vinte + noi.perse, noi.giocate, "vinte + perse = giocate"); + assert.ok(noi.punti >= 0 && noi.pos > 0); + const posizioni = csi!.classifica.map((r) => r.pos); + assert.deepEqual( + posizioni, + [...posizioni].sort((a, b) => a - b), + "ordinata per posizione", + ); + }); + + await prova("ogni partita è coerente con il formato dell'app", () => { + for (const p of csi?.partite ?? []) { + assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`); + assert.ok(p.avversario.length > 0, `${p.id}: avversario valorizzato`); + assert.ok(!isNostraSquadra(p.avversario), `${p.id}: non giochiamo contro noi stessi`); + const giocata = p.setNostri !== null && p.setLoro !== null; + if (giocata) assert.equal(Math.max(p.setNostri!, p.setLoro!), 3, `${p.id}: si vince a 3 set`); + else assert.equal(p.parziali.length, 0, `${p.id}: gara futura senza parziali`); + } + }); + + await prova("la seconda chiamata arriva dalla cache del server", async () => { + const t0 = Date.now(); + const secondo = (await json(await fetch(url("/api/public/csi")))) as DatiCsi; + assert.equal(secondo.aggiornato, csi?.aggiornato, "stesso timestamp: nessuna nuova fetch"); + assert.ok(Date.now() - t0 < 2_000, "risposta immediata"); + }); + + // --- GET /api/public/push-config ------------------------------------------- + await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => { + const res = await fetch(url("/api/public/push-config")); + assert.equal(res.status, 200); + const dati = (await json(res)) as Record<string, unknown>; + assert.deepEqual(Object.keys(dati), ["publicKey"], "nessun altro dato esposto"); + assert.ok(dati["publicKey"] === null || typeof dati["publicKey"] === "string"); + }); + + // --- validazione degli input (nessuna scrittura) --------------------------- + await prova("push-subscribe rifiuta i payload non validi", async () => { + assert.equal((await postJson("/api/public/push-subscribe", {})).status, 400); + assert.equal( + ( + await postJson("/api/public/push-subscribe", { + endpoint: "non-un-url", + giocatoreId: "g1", + p256dh: "x", + auth: "y", + }) + ).status, + 400, + "endpoint deve essere un URL", + ); + assert.equal( + ( + await postJson("/api/public/push-subscribe", { + endpoint: `https://push.example/${"x".repeat(1100)}`, + giocatoreId: "g1", + p256dh: "x", + auth: "y", + }) + ).status, + 400, + "endpoint troppo lungo", + ); + const res = await fetch(url("/api/public/push-subscribe"), { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assert.equal(res.status, 400, "anche la cancellazione valida l'input"); + }); + + await prova("push-messaggio e sollecita-presenze rifiutano i payload non validi", async () => { + assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400); + assert.equal((await postJson("/api/public/sollecita-presenze", {})).status, 400); + assert.equal( + (await postJson("/api/public/sollecita-presenze", { eventoId: "" })).status, + 400, + "eventoId vuoto non è valido", + ); + }); + + // --- endpoint che leggono dal database ------------------------------------- + if (haSupabase()) { + await prova("push-messaggio risponde con il messaggio di default", async () => { + const res = await postJson("/api/public/push-messaggio", { + endpoint: `https://push.example/test-${Date.now()}`, + }); + assert.equal(res.status, 200); + const dati = (await json(res)) as { title?: string; body?: string }; + assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo"); + }); + + await prova("sollecita-presenze su un evento inesistente risponde 404", async () => { + const res = await postJson("/api/public/sollecita-presenze", { eventoId: "non-esiste" }); + assert.equal(res.status, 404); + }); + } else { + salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate"); + } + + // --- metodi non previsti --------------------------------------------------- + await prova("un GET su un endpoint POST non esegue l'handler", async () => { + const res = await fetch(url("/api/public/sollecita-presenze")); + // TanStack Start serve la pagina invece del 405: l'importante è che il + // job non parta e che nessuna notifica venga inviata. + assert.match(res.headers.get("content-type") ?? "", /text\/html/); + assert.doesNotMatch(await res.text(), /"inviate"/); + }); + + riepilogo("integrazione API"); +} finally { + server.stop(); +} diff --git a/test/integration/schema-profili.test.ts b/test/integration/schema-profili.test.ts new file mode 100644 index 0000000..28fd4b4 --- /dev/null +++ b/test/integration/schema-profili.test.ts @@ -0,0 +1,140 @@ +/** + * Schema e permessi del Profilo Giocatore: `bun test/integration/schema-profili.test.ts`. + * + * Verifica contro un database vero (locale con `npx supabase start`, oppure quello + * configurato in `.env`) le tre cose che il codice dà per scontate: le colonne della + * tabella, la chiusura verso l'utente anonimo e il bucket privato. + * + * Salta con un motivo esplicito quando mancano le credenziali o quando le migration + * M2/M3 non sono ancora applicate a quel database: sono stati dell'ambiente, non difetti. + */ +import assert from "node:assert/strict"; +import { COLONNE_PROFILO } from "@/lib/profili-core"; +import { envDaFile } from "../helpers/server"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const env = { ...envDaFile(), ...process.env }; +const URL_BASE = env["SUPABASE_URL"]; +const CHIAVE_SERVIZIO = env["SUPABASE_SERVICE_ROLE_KEY"]; +const CHIAVE_PUBBLICA = env["SUPABASE_PUBLISHABLE_KEY"]; + +/** Le chiavi nuove sono opache e viaggiano solo in `apikey`; quelle legacy sono JWT. */ +function intestazioni(chiave: string): Record<string, string> { + const base: Record<string, string> = { apikey: chiave, "content-type": "application/json" }; + if (chiave.startsWith("eyJ")) base["Authorization"] = `Bearer ${chiave}`; + return base; +} + +const rest = (percorso: string, chiave: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { ...intestazioni(chiave), ...(init?.headers ?? {}) }, + }); + +const bucketProfili = (chiave: string) => + fetch(`${URL_BASE}/storage/v1/bucket/profili-giocatore`, { headers: intestazioni(chiave) }); + +console.log(`schema profili su ${URL_BASE ?? "(non configurato)"}`); + +if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) { + salta("schema e permessi dei profili", "credenziali Supabase non configurate"); + riepilogo("schema profili"); +} else { + try { + // --- M1: anagrafica della squadra ------------------------------------------ + await prova("la rosa di M1 è popolata e collegabile agli account", async () => { + const res = await rest( + "giocatori_squadra?select=id,nome,cognome,auth_user_id,attivo", + CHIAVE_SERVIZIO, + ); + assert.equal(res.status, 200); + const righe = (await res.json()) as Array<{ id: string }>; + assert.ok(righe.length >= 17, `attesi almeno 17 giocatori, trovati ${righe.length}`); + assert.ok( + righe.every((r) => /^g[0-9]+$/.test(r.id)), + "gli ID restano nel formato g1..gN (DD-012)", + ); + }); + + // Attenzione al falso verde: su un UPDATE che non tocca nessuna riga PostgREST + // risponde comunque 2xx. Quello che conta è che il dato non cambi. + await prova("un anonimo non si collega a uno slot della rosa", async () => { + const res = await rest("giocatori_squadra?id=eq.g1", CHIAVE_PUBBLICA, { + method: "PATCH", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ auth_user_id: "00000000-0000-0000-0000-000000000000" }), + }); + if (res.ok) { + const aggiornate = (await res.json()) as unknown[]; + assert.equal(aggiornate.length, 0, "nessuna riga aggiornata senza sessione"); + } + const dopo = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO); + const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>; + assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero"); + }); + + // --- M2: tabella dei profili ----------------------------------------------- + const m2 = await rest("profili_giocatore?select=giocatore_id&limit=1", CHIAVE_SERVIZIO); + if (!m2.ok) { + salta("schema e permessi di profili_giocatore", "M2 non applicata (npx supabase db push)"); + } else { + await prova("profili_giocatore espone tutte le colonne che il codice legge", async () => { + const colonne = COLONNE_PROFILO.replace(/\s/g, ""); + const res = await rest(`profili_giocatore?select=${colonne}&limit=1`, CHIAVE_SERVIZIO); + const corpo = await res.text(); + assert.equal(res.status, 200, corpo); + assert.ok(Array.isArray(JSON.parse(corpo))); + }); + + await prova("il documento ha due facciate separate", async () => { + const res = await rest( + "profili_giocatore?select=documento_fronte_path,documento_retro_path&limit=1", + CHIAVE_SERVIZIO, + ); + assert.equal(res.status, 200, "fronte e retro sono colonne distinte"); + }); + + // Il punto della RLS: senza sessione non si legge e non si scrive. È la garanzia su + // cui si regge tutto il modulo, e a nessuno serve fidarsi della prosa. + await prova("un anonimo non legge i profili", async () => { + const res = await rest("profili_giocatore?select=giocatore_id", CHIAVE_PUBBLICA); + if (res.ok) { + const righe = (await res.json()) as unknown[]; + assert.equal(righe.length, 0, "nessuna riga visibile senza sessione"); + } else { + assert.ok(res.status >= 400, `accesso negato (${res.status})`); + } + }); + + await prova("un anonimo non scrive i profili", async () => { + const res = await rest("profili_giocatore", CHIAVE_PUBBLICA, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ giocatore_id: "g1", telefono: "000" }), + }); + assert.ok(!res.ok, `la scrittura anonima deve fallire, invece ha risposto ${res.status}`); + }); + } + + // --- M3: bucket privato ------------------------------------------------------ + const m3 = await bucketProfili(CHIAVE_SERVIZIO); + if (!m3.ok) { + salta("bucket dei documenti", "M3 non applicata (npx supabase db push)"); + } else { + await prova("il bucket dei documenti è privato", async () => { + const bucket = (await m3.json()) as { public?: boolean }; + assert.equal(bucket.public, false, "documenti e certificati non sono mai pubblici"); + }); + + await prova("un anonimo non scarica i file dei profili", async () => { + const res = await fetch( + `${URL_BASE}/storage/v1/object/profili-giocatore/g1/certificato.pdf`, + { headers: intestazioni(CHIAVE_PUBBLICA) }, + ); + assert.ok(!res.ok, `nessun accesso anonimo allo storage (${res.status})`); + }); + } + } finally { + riepilogo("schema profili"); + } +} diff --git a/test/run.ts b/test/run.ts new file mode 100644 index 0000000..e7010cd --- /dev/null +++ b/test/run.ts @@ -0,0 +1,45 @@ +/** + * Esecutore della suite: `bun test/run.ts [unit|integration|e2e|all]`. + * Ogni file gira in un processo separato, così un test non può inquinare gli altri. + * Senza argomenti esegue solo gli unit test (non serve rete né database). + */ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; + +const cartelle = { unit: "test/unit", integration: "test/integration", e2e: "test/e2e" }; +type Gruppo = keyof typeof cartelle; + +const argomento = (process.argv[2] ?? "unit") as Gruppo | "all"; +const gruppi: Gruppo[] = + argomento === "all" ? (Object.keys(cartelle) as Gruppo[]) : [argomento as Gruppo]; + +for (const g of gruppi) { + if (!cartelle[g]) { + console.error(`Gruppo sconosciuto: ${g}. Usa unit, integration, e2e oppure all.`); + process.exit(2); + } +} + +const falliti: string[] = []; +let totali = 0; + +for (const gruppo of gruppi) { + const cartella = cartelle[gruppo]; + const file = readdirSync(cartella) + .filter((f) => f.endsWith(".test.ts")) + .sort(); + + console.log(`\n── ${gruppo} (${file.length} file)`); + for (const nome of file) { + const percorso = `${cartella}/${nome}`; + totali += 1; + const esito = spawnSync("bun", [percorso], { stdio: "inherit" }); + if (esito.status !== 0) falliti.push(percorso); + } +} + +console.log( + `\n${totali - falliti.length}/${totali} file ok` + + (falliti.length ? `\nfalliti:\n ${falliti.join("\n ")}` : ""), +); +process.exit(falliti.length ? 1 : 0); diff --git a/test/unit/badge-social.test.ts b/test/unit/badge-social.test.ts new file mode 100644 index 0000000..e58b009 --- /dev/null +++ b/test/unit/badge-social.test.ts @@ -0,0 +1,62 @@ +/** Check dei badge social: `bun test/unit/badge-social.test.ts`. */ +import assert from "node:assert/strict"; +import { + badgeSocialVinti, + categorieSocial, + conteggioCategoria, + mioVotoSocial, + vincitoreCategoria, + type VotoSocial, +} from "@/lib/badge-social"; + +const v = ( + match_id: string, + categoria: string, + votante_id: string, + votato_id: string, + votato_nome: string, +): VotoSocial => ({ match_id, categoria, votante_id, votato_id, votato_nome }); + +const voti: VotoSocial[] = [ + v("m1", "affidabile", "g1", "g2", "Bruno"), + v("m1", "affidabile", "g3", "g2", "Bruno"), + v("m1", "affidabile", "g4", "g5", "Anna"), + v("m1", "meme", "g1", "g5", "Anna"), + v("m2", "affidabile", "g1", "g2", "Bruno"), +]; + +// --- conteggioCategoria: match e categoria isolati --------------------------- +assert.deepEqual(conteggioCategoria(voti, "m1", "affidabile"), [ + { id: "g2", nome: "Bruno", voti: 2 }, + { id: "g5", nome: "Anna", voti: 1 }, +]); +assert.deepEqual(conteggioCategoria(voti, "m1", "meme"), [{ id: "g5", nome: "Anna", voti: 1 }]); +assert.deepEqual(conteggioCategoria(voti, "m1", "cuore"), [], "categoria senza voti"); +assert.deepEqual(conteggioCategoria(voti, "m9", "affidabile"), []); + +// --- vincitoreCategoria: serve un vantaggio netto ---------------------------- +assert.equal(vincitoreCategoria(voti, "m1", "affidabile")?.nome, "Bruno"); +assert.equal(vincitoreCategoria(voti, "m1", "cuore"), null, "nessun voto, nessun vincitore"); +const pari = [v("m3", "meme", "g1", "g2", "Bruno"), v("m3", "meme", "g2", "g5", "Anna")]; +assert.equal(vincitoreCategoria(pari, "m3", "meme"), null, "parità: nessun vincitore"); + +// --- mioVotoSocial ----------------------------------------------------------- +assert.equal(mioVotoSocial(voti, "m1", "affidabile", "g1")?.votato_id, "g2"); +assert.equal(mioVotoSocial(voti, "m1", "meme", "g3"), null, "non ho votato questa categoria"); + +// --- badgeSocialVinti: quante volte ho vinto ciascuna categoria -------------- +assert.deepEqual(badgeSocialVinti(voti, "g2"), { affidabile: 2 }, "vinta in m1 e m2"); +assert.deepEqual(badgeSocialVinti(voti, "g5"), { meme: 1 }); +assert.deepEqual(badgeSocialVinti(voti, "g9"), {}, "chi non vince non ha badge"); +assert.deepEqual(badgeSocialVinti(pari, "g2"), {}, "una parità non assegna badge"); +assert.deepEqual(badgeSocialVinti([], "g2"), {}); + +// --- invarianti sulle categorie ---------------------------------------------- +assert.equal( + new Set(categorieSocial.map((c) => c.id)).size, + categorieSocial.length, + "id categoria unici", +); +for (const c of categorieSocial) assert.ok(c.nome && c.descrizione && c.emoji); + +console.log("badge-social: ok"); diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts new file mode 100644 index 0000000..37b4ed9 --- /dev/null +++ b/test/unit/badges.test.ts @@ -0,0 +1,166 @@ +/** Check dei badge e dei gradi: `bun src/lib/badges.test.ts`. */ +import assert from "node:assert/strict"; +import { giocatori, type Giocatore } from "@/lib/crapp-data"; +import { + badgeDefs, + badgeGiocatore, + badgeSegreti, + badgeSegretiSbloccati, + collezioneBadge, + descrizioneSoglie, + gradiOrdine, + gradoRaggiunto, + mancanoPer, + microcopyBadge, + prossimoTraguardo, + segretiNascosti, + statoBadge, + tuttiBadge, +} from "@/lib/badges"; + +/** Giocatore azzerato: ogni test dichiara solo i valori che gli servono. */ +function g(valori: Partial<Giocatore> = {}): Giocatore { + return { + ...giocatori[0]!, + presenze: 0, + mvp: 0, + mediaVoto: 0, + palloni: 0, + cacche: 0, + cacchePartita: 0, + infortuni: 0, + ritardi: 0, + streak: 0, + serieAllenamenti: 0, + seriePartite: 0, + serieConferme: 0, + ...valori, + }; +} + +const mvpDef = badgeDefs.find((b) => b.id === "mvp")!; + +// --- gradoRaggiunto: soglie 1 / 3 / 5 ---------------------------------------- +assert.equal(gradoRaggiunto(mvpDef, 0), null, "sotto la prima soglia nessun grado"); +assert.equal(gradoRaggiunto(mvpDef, 1), "bronzo", "la soglia è inclusiva"); +assert.equal(gradoRaggiunto(mvpDef, 2), "bronzo"); +assert.equal(gradoRaggiunto(mvpDef, 3), "argento"); +assert.equal(gradoRaggiunto(mvpDef, 99), "oro", "oltre l'oro resta oro"); +assert.deepEqual(gradiOrdine, ["bronzo", "argento", "oro"]); + +// --- statoBadge: progresso verso la soglia successiva ------------------------ +const aMeta = statoBadge(mvpDef, g({ mvp: 2 })); +assert.deepEqual( + [aMeta.grado, aMeta.prossimo, aMeta.prossimaSoglia, aMeta.progresso], + ["bronzo", "argento", 3, 67], +); +const alMassimo = statoBadge(mvpDef, g({ mvp: 5 })); +assert.deepEqual( + [alMassimo.grado, alMassimo.prossimo, alMassimo.prossimaSoglia], + ["oro", null, null], +); +assert.equal(alMassimo.progresso, 100, "raggiunto l'oro il progresso è pieno"); + +// La media voto usa soglie decimali: non deve arrotondare per eccesso. +const pagella = badgeDefs.find((b) => b.id === "pagella")!; +assert.equal(gradoRaggiunto(pagella, 6.4), null); +assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo"); + +// --- badgeGiocatore ---------------------------------------------------------- +assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); +assert.ok( + badgeGiocatore(g()).every((b) => b.grado === null), + "un giocatore a zero non ha gradi", +); + +// --- badge segreti: invisibili finché non si sbloccano ------------------------ +const nessunSegreto = g({ mvp: 2, mediaVoto: 7.9 }); +assert.equal(badgeSegretiSbloccati(nessunSegreto).length, 0, "serve media 8, non 7.9"); +assert.equal(segretiNascosti(nessunSegreto), badgeSegreti.length); + +const tiebreak = badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8 })); +assert.deepEqual( + tiebreak.map((b) => b.def.id), + ["s-tiebreak"], + "sblocca solo il segreto il cui requisito è soddisfatto", +); + +assert.deepEqual( + badgeSegretiSbloccati(g({ infortuni: 3 })).map((b) => b.def.id), + ["s-infermeria"], +); +assert.equal(badgeSegretiSbloccati(g({ infortuni: 2 })).length, 0, "2 infortuni non bastano"); +assert.deepEqual( + badgeSegretiSbloccati(g({ ritardi: 5 })).map((b) => b.def.id), + ["s-ritardi"], +); +assert.deepEqual( + badgeSegretiSbloccati(g({ cacche: 3 })).map((b) => b.def.id), + ["s-cacche"], +); +assert.deepEqual( + badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id), + ["s-mai-forfait"], +); +assert.equal( + badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 14 })).length, + 0, + "servono entrambe le condizioni", +); + +// --- collezioneBadge --------------------------------------------------------- +const vuota = collezioneBadge(g()); +assert.equal(vuota.ottenuti, 0); +assert.equal(vuota.totali, tuttiBadge.length); +assert.equal(vuota.sbloccati.length, 0); +assert.equal(vuota.inProgresso.length, badgeDefs.length); +assert.equal(vuota.nascosti, badgeSegreti.length); + +const piena = collezioneBadge(g({ mvp: 5, mediaVoto: 8, presenze: 30, infortuni: 3 })); +assert.equal( + piena.ottenuti, + piena.sbloccati.length + piena.segreti.length, + "gli ottenuti sommano normali e segreti", +); +assert.equal(piena.nascosti, badgeSegreti.length - piena.segreti.length); +assert.ok(piena.ottenuti > 0 && piena.ottenuti < piena.totali); + +// --- prossimoTraguardo: il badge in proporzione più vicino ------------------- +const vicino = prossimoTraguardo(g({ mvp: 2, presenze: 1 })); +assert.equal(vicino?.def.id, "mvp", "67% batte 20%"); +assert.equal( + prossimoTraguardo( + g({ + mvp: 5, + mediaVoto: 10, + palloni: 10, + presenze: 30, + serieAllenamenti: 10, + serieConferme: 15, + }), + ), + null, + "tutto al massimo: nessun traguardo residuo", +); + +// --- microcopy --------------------------------------------------------------- +assert.equal(mancanoPer(statoBadge(mvpDef, g({ mvp: 2 }))), "Ti mancano 1 MVP per il argento"); +assert.equal(mancanoPer(alMassimo), "Livello massimo raggiunto"); +assert.equal(microcopyBadge(alMassimo), "Hai fatto tutto: badge d'oro in bacheca."); +assert.equal(microcopyBadge(aMeta), "Sei in piena corsa, continua così."); +assert.equal(microcopyBadge(statoBadge(mvpDef, g())), "Ogni partita conta: si parte da qui."); +assert.equal(descrizioneSoglie(mvpDef), "1/3/5 MVP"); + +// --- invarianti sulle definizioni -------------------------------------------- +for (const def of tuttiBadge) { + assert.ok(def.soglie.bronzo <= def.soglie.argento, `${def.id}: soglie crescenti`); + assert.ok(def.soglie.argento <= def.soglie.oro, `${def.id}: soglie crescenti`); + assert.ok(def.nome && def.descrizione && def.unita, `${def.id}: testi presenti`); +} +assert.equal( + new Set(tuttiBadge.map((b) => b.id)).size, + tuttiBadge.length, + "gli id dei badge sono unici", +); + +console.log("badges: ok"); diff --git a/test/unit/cacche.test.ts b/test/unit/cacche.test.ts new file mode 100644 index 0000000..8f8abdd --- /dev/null +++ b/test/unit/cacche.test.ts @@ -0,0 +1,55 @@ +/** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */ +import assert from "node:assert/strict"; +import { + mediaPartita, + mediaStagione, + recordStagione, + statisticheCacche, + type RigaCacche, +} from "@/lib/cacche"; + +const r = (evento_id: string, giocatore_id: string, quantita: number): RigaCacche => ({ + evento_id, + giocatore_id, + quantita, +}); + +const righe: RigaCacche[] = [ + r("e1", "g1", 3), + r("e2", "g1", 4), + r("e1", "g2", 1), + r("e2", "g2", 2), +]; + +// --- statisticheCacche ------------------------------------------------------- +const stat = statisticheCacche(righe); +assert.deepEqual(stat["g1"], { totale: 7, giornate: 2, media: 3.5, record: 4, giornateTop: 2 }); +assert.deepEqual(stat["g2"], { totale: 3, giornate: 2, media: 1.5, record: 2, giornateTop: 0 }); +assert.deepEqual(statisticheCacche([]), {}); + +// giornateTop è la soglia del badge segreto: 3 conta, 2 no. +assert.equal(statisticheCacche([r("e1", "g9", 3)])["g9"]?.giornateTop, 1); +assert.equal(statisticheCacche([r("e1", "g9", 2)])["g9"]?.giornateTop, 0); + +// Uno zero dichiarato è una giornata censita, non un dato mancante. +const conZero = statisticheCacche([r("e1", "g7", 0)])["g7"]!; +assert.deepEqual([conZero.giornate, conZero.media, conZero.record], [1, 0, 0]); + +// --- medie ------------------------------------------------------------------- +assert.equal(mediaPartita(righe, "e1"), 2, "(3+1)/2"); +assert.equal(mediaPartita(righe, "e2"), 3, "(4+2)/2"); +assert.equal(mediaPartita(righe, "inesistente"), 0, "nessun dato: zero, non NaN"); +assert.equal(mediaStagione(righe), 2.5); +assert.equal(mediaStagione([]), 0); +assert.equal(mediaStagione([r("e1", "g1", 1), r("e1", "g2", 2), r("e1", "g3", 2)]), 1.7); + +// --- recordStagione ---------------------------------------------------------- +assert.deepEqual(recordStagione(righe), r("e2", "g1", 4)); +assert.equal(recordStagione([]), null); +assert.deepEqual( + recordStagione([r("e1", "g1", 4), r("e2", "g2", 4)]), + r("e1", "g1", 4), + "a parità vince chi l'ha fatto per primo", +); + +console.log("cacche: ok"); diff --git a/test/unit/crapp-data.test.ts b/test/unit/crapp-data.test.ts new file mode 100644 index 0000000..c83b0a0 --- /dev/null +++ b/test/unit/crapp-data.test.ts @@ -0,0 +1,66 @@ +/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */ +import assert from "node:assert/strict"; +import { classifica, formatData, giocatori, statoMeta, storicoMatch } from "@/lib/crapp-data"; + +// --- rosa -------------------------------------------------------------------- +assert.ok(giocatori.length > 0, "la rosa non è vuota"); +assert.deepEqual( + giocatori.map((g) => g.id), + giocatori.map((_, i) => `g${i + 1}`), + "gli id sono progressivi g1..gN: le tabelle del database vi si appoggiano", +); +assert.equal( + new Set(giocatori.map((g) => g.numero)).size, + giocatori.length, + "numeri di maglia unici", +); +for (const g of giocatori) { + assert.ok(g.nome.trim().length > 0 && g.ruolo.trim().length > 0, `${g.id}: nome e ruolo`); + assert.match(g.nascita, /^\d{4}-\d{2}-\d{2}$/, `${g.id}: data di nascita valida`); + assert.ok(g.numero > 0, `${g.id}: numero di maglia positivo`); + assert.equal(g.iniziali.length, 2, `${g.id}: due iniziali`); + assert.equal(g.iniziali, g.iniziali.toUpperCase(), `${g.id}: iniziali maiuscole`); + assert.ok(g.mediaVoto >= 0 && g.mediaVoto <= 10, `${g.id}: media voto nel range 1-10`); + assert.ok(g.presenze <= g.totaliEventi, `${g.id}: presenze mai oltre gli eventi totali`); +} + +// --- formatData -------------------------------------------------------------- +assert.equal(formatData("2026-09-01"), "mar 01 settembre"); +assert.equal(formatData("2026-01-31"), "sab 31 gennaio"); +assert.ok( + !formatData("2026-03-29").includes("28"), + "cambio ora legale: la data non slitta al giorno prima", +); + +// --- stati presenza ---------------------------------------------------------- +assert.deepEqual(Object.keys(statoMeta), [ + "presente", + "assente", + "forse", + "ritardo", + "infortunato", +]); +for (const [stato, meta] of Object.entries(statoMeta)) { + assert.ok(meta.label && meta.emoji && meta.className, `${stato}: metadati completi`); +} + +// --- dati demo residui ------------------------------------------------------- +assert.equal(new Set(storicoMatch.map((m) => m.id)).size, storicoMatch.length, "id partite unici"); +for (const m of storicoMatch) { + assert.equal( + m.parziali.length, + m.setNostri + m.setLoro, + `${m.id}: un parziale per ogni set giocato`, + ); + assert.ok(m.setNostri === 3 || m.setLoro === 3, `${m.id}: una partita finisce a 3 set vinti`); +} +assert.deepEqual( + classifica.map((r) => r.pos), + classifica.map((_, i) => i + 1), + "la classifica di partenza è numerata in ordine", +); +for (const r of classifica) { + assert.equal(r.vinte + r.perse, r.giocate, `${r.squadra}: vinte + perse = giocate`); +} + +console.log("crapp-data: ok"); diff --git a/test/unit/csi-core.test.ts b/test/unit/csi-core.test.ts new file mode 100644 index 0000000..a907b79 --- /dev/null +++ b/test/unit/csi-core.test.ts @@ -0,0 +1,113 @@ +/** + * Check del parsing CSI: `bun src/lib/csi-core.test.ts`. + * Con CSI_LIVE=1 verifica anche gli endpoint reali del portale. + */ +import assert from "node:assert/strict"; +import { + isNostraSquadra, + parseClassifica, + partiteDaEventi, + partiteGiocate, + urlClassifica, + urlPartite, +} from "@/lib/csi-core"; + +const htmlClassifica = ` +<table class="ranking"> + <tr><th>Pos</th><th>Squadra</th></tr> + <tr><td>1</td><td><a href="team_details.php?team_id=1">Altra Squadra Girone A</a></td> + <td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>42</td><td>18</td><td>24</td></tr> +</table> +<table class="ranking"> + <tr><th>Pos</th><th>Squadra</th></tr> + <tr><td>1</td><td><a href="team_details.php?team_id=9">Paolo Poggi Volley & C.</a></td> + <td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>43</td><td>17</td><td>26</td></tr> + <tr class="align-middle"> + <!-- Posizione --><td>4</td> + <td class="d-flex"><img src="images/teams/logos/CRAP_Volley_3359.jpg" alt=""> + <a href="team_details.php?team_id=3359">C.R.A.P. Volley </a></td> + <td><b>30</b></td><td>16</td><td>10</td><td>6</td><td>3</td><td>3</td><td>36</td><td>28</td><td>8</td> + <td>1427</td><td>1329</td><td>98</td><td>0.56</td><td>1.07</td> + <td><svg viewBox="0 0 16 16"><path d="M16 8A8"/></svg></td> + </tr> +</table>`; + +const righe = parseClassifica(htmlClassifica); +assert.equal(righe.length, 2, "prende solo il girone che contiene la nostra squadra"); +assert.deepEqual(righe[1], { + pos: 4, + squadra: "C.R.A.P. Volley", + punti: 30, + giocate: 16, + vinte: 10, + perse: 6, + setFatti: 36, + setSubiti: 28, +}); +assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML"); +assert.deepEqual(parseClassifica("<p>pagina cambiata</p>"), [], "portale cambiato: nessun crash"); + +assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley ")); +assert.ok(!isNostraSquadra("CRAP Volley B")); + +const eventi = [ + { + id: 1, + start: "2025-11-12T22:00:00", + team1: "C.R.A.P. Volley", + team2: "AMCM", + result: "3 - 1", + partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>", + field: "Sabin Piccolo", + project: "PVM - Campionato Open Misto Eccellenza", + }, + { + id: 2, + start: "2025-11-19T22:00:00", + team1: "Gnu Tim SdC", + team2: "C.R.A.P. Volley", + result: "3 - 2", + partials: "25 - 21</br>18 - 25</br>", + field: "BVI", + project: "PVM - Campionato Open Misto Eccellenza", + }, + { id: 3, start: "2026-09-10T21:00:00", team1: "C.R.A.P. Volley", team2: "Esselle", result: "" }, + { id: 4, start: "2025-10-01T21:00:00", team1: "Esselle", team2: "AMCM", result: "3 - 0" }, +]; + +const partite = partiteDaEventi(eventi); +assert.equal(partite.length, 3, "scarta le gare di altre squadre"); +assert.equal(partite[0]!.data, "2026-09-10", "ordinate dalla più recente"); +assert.equal(partite[0]!.setNostri, null, "gara futura senza risultato"); + +const casa = partite.find((p) => p.id === "1")!; +assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]); +assert.deepEqual(casa.parziali[1], [23, 25]); +assert.equal(casa.ora, "22:00"); + +const trasferta = partite.find((p) => p.id === "2")!; +assert.deepEqual( + [trasferta.casa, trasferta.avversario, trasferta.setNostri, trasferta.setLoro], + [false, "Gnu Tim SdC", 2, 3], + "in trasferta il punteggio va ribaltato", +); +assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali"); + +assert.equal(partiteGiocate(partite).length, 2); +assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash"); + +if (process.env["CSI_LIVE"]) { + const [html, json] = await Promise.all([ + fetch(urlClassifica()).then((r) => r.text()), + fetch(urlPartite()).then((r) => r.json()), + ]); + const live = parseClassifica(html); + const nostra = live.find((r) => isNostraSquadra(r.squadra)); + assert.ok(nostra, "la nostra squadra è in classifica"); + assert.ok(live.length >= 8 && nostra.giocate > 0); + const livePartite = partiteDaEventi(json); + assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0); + console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`); +} + +console.log("csi-core: ok"); diff --git a/test/unit/eventi.test.ts b/test/unit/eventi.test.ts new file mode 100644 index 0000000..2bc3da7 --- /dev/null +++ b/test/unit/eventi.test.ts @@ -0,0 +1,64 @@ +/** Check della conversione eventi: `bun test/unit/eventi.test.ts`. */ +import assert from "node:assert/strict"; +import { categoriaEvento, daCategoria, daRiga, type RigaEvento } from "@/lib/eventi"; + +const riga: RigaEvento = { + id: "e1", + tipo: "partita", + titolo: "CRAP - Avversari", + luogo: "PalaCRAP", + data: "2026-09-01", + ora: "21:00", + note: "portare la seconda maglia", + convocati: ["g1", "g2"], + campionato: true, + casa: true, + pagelle_chiuse: false, +}; + +// --- daRiga: i NULL del database diventano valori sicuri --------------------- +assert.deepEqual(daRiga(riga), { + id: "e1", + tipo: "partita", + titolo: "CRAP - Avversari", + luogo: "PalaCRAP", + data: "2026-09-01", + ora: "21:00", + note: "portare la seconda maglia", + convocati: ["g1", "g2"], + campionato: true, + casa: true, + pagelleChiuse: false, +}); + +const vuota = daRiga({ + ...riga, + note: null, + convocati: null, + casa: null, + campionato: false, + pagelle_chiuse: false, +}); +assert.equal(vuota.note, "", "note NULL → stringa vuota"); +assert.deepEqual(vuota.convocati, [], "convocati NULL → tutta la rosa (array vuoto)"); +assert.equal(vuota.casa, true, "casa NULL → si gioca in casa"); +assert.equal(vuota.pagelleChiuse, false); + +// --- categoriaEvento: l'amichevole è una partita fuori campionato ------------ +assert.equal(categoriaEvento({ tipo: "partita", campionato: true }), "partita"); +assert.equal(categoriaEvento({ tipo: "partita", campionato: false }), "amichevole"); +assert.equal(categoriaEvento({ tipo: "allenamento", campionato: false }), "allenamento"); +assert.equal(categoriaEvento({ tipo: "evento", campionato: false }), "evento"); +assert.equal(categoriaEvento({ tipo: "compleanno", campionato: false }), "evento"); + +// --- daCategoria: inverso di categoriaEvento --------------------------------- +assert.deepEqual(daCategoria("partita"), { tipo: "partita", campionato: true }); +assert.deepEqual(daCategoria("amichevole"), { tipo: "partita", campionato: false }); +assert.deepEqual(daCategoria("allenamento"), { tipo: "allenamento", campionato: false }); +assert.deepEqual(daCategoria("evento"), { tipo: "evento", campionato: false }); + +for (const c of ["partita", "amichevole", "allenamento", "evento"] as const) { + assert.equal(categoriaEvento(daCategoria(c)), c, `andata e ritorno stabile per ${c}`); +} + +console.log("eventi: ok"); diff --git a/test/unit/infortuni.test.ts b/test/unit/infortuni.test.ts new file mode 100644 index 0000000..f7f6a4a --- /dev/null +++ b/test/unit/infortuni.test.ts @@ -0,0 +1,33 @@ +/** Check di infortuni e ritardi: `bun test/unit/infortuni.test.ts`. */ +import assert from "node:assert/strict"; +import { conInfortuni, contaInfortuni, contaRitardi } from "@/lib/infortuni"; +import { giocatori } from "@/lib/crapp-data"; +import type { MappaPresenze } from "@/lib/presenze"; + +const presenze: MappaPresenze = { + e1: { g1: "infortunato", g2: "presente", g3: "ritardo" }, + e2: { g1: "infortunato", g2: "ritardo", g3: "assente" }, + e3: { g1: "presente", g2: "forse" }, +}; + +// --- conteggi ---------------------------------------------------------------- +assert.deepEqual(contaInfortuni(presenze), { g1: 2 }, "ogni evento vale una volta sola"); +assert.deepEqual(contaRitardi(presenze), { g3: 1, g2: 1 }); +assert.deepEqual(contaInfortuni({}), {}); +assert.deepEqual(contaRitardi({ e1: {} }), {}, "evento senza risposte: nessun conteggio"); + +// --- conInfortuni: arricchisce il giocatore senza perdere gli altri campi ---- +const base = giocatori.find((g) => g.id === "g1")!; +const arricchito = conInfortuni(base, contaInfortuni(presenze), contaRitardi(presenze)); +assert.equal(arricchito.infortuni, 2); +assert.equal(arricchito.ritardi, 0, "g1 non ha ritardi"); +assert.equal(arricchito.nome, base.nome, "il resto del giocatore resta intatto"); +assert.equal(base.infortuni, 0, "l'originale non viene mutato"); + +const senzaDati = conInfortuni(base, {}); +assert.deepEqual([senzaDati.infortuni, senzaDati.ritardi], [0, 0], "assenza di dati = zero"); + +const g3 = giocatori.find((g) => g.id === "g3")!; +assert.equal(conInfortuni(g3, contaInfortuni(presenze), contaRitardi(presenze)).ritardi, 1); + +console.log("infortuni: ok"); diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts new file mode 100644 index 0000000..ffcc140 --- /dev/null +++ b/test/unit/mvp-voti.test.ts @@ -0,0 +1,53 @@ +/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ +import assert from "node:assert/strict"; +import { conteggioPartita, mioVoto, vincitoriMvp, type VotoMvp } from "@/lib/mvp-voti"; + +const v = ( + match_id: string, + votante_id: string, + votato_id: string, + votato_nome: string, +): VotoMvp => ({ + match_id, + votante_id, + votato_id, + votato_nome, +}); + +// --- conteggioPartita -------------------------------------------------------- +const partita = [ + v("m1", "g1", "g2", "Bruno"), + v("m1", "g3", "g2", "Bruno"), + v("m1", "g4", "g5", "Anna"), + v("m2", "g1", "g5", "Anna"), +]; + +assert.deepEqual(conteggioPartita(partita, "m1"), [ + { id: "g2", nome: "Bruno", voti: 2 }, + { id: "g5", nome: "Anna", voti: 1 }, +]); +assert.deepEqual(conteggioPartita(partita, "inesistente"), []); + +// A parità di voti l'ordine è alfabetico, così la UI è stabile. +const pari = [v("m3", "g1", "g9", "Zeno"), v("m3", "g2", "g8", "Anna")]; +assert.deepEqual( + conteggioPartita(pari, "m3").map((c) => c.nome), + ["Anna", "Zeno"], +); + +// --- vincitoriMvp: la parità non assegna nessun MVP -------------------------- +assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); +assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); +assert.deepEqual(vincitoriMvp([]), {}); +assert.deepEqual( + vincitoriMvp([v("m4", "g1", "g2", "Solo")]), + { m4: "Solo" }, + "un solo votante basta se non c'è concorrenza", +); + +// --- mioVoto ----------------------------------------------------------------- +assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); +assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); +assert.equal(mioVoto(partita, "m9", "g1"), null); + +console.log("mvp-voti: ok"); diff --git a/test/unit/notifiche-smart.test.ts b/test/unit/notifiche-smart.test.ts new file mode 100644 index 0000000..72a3245 --- /dev/null +++ b/test/unit/notifiche-smart.test.ts @@ -0,0 +1,106 @@ +/** Check delle notifiche smart: `bun test/unit/notifiche-smart.test.ts`. */ +import assert from "node:assert/strict"; +import { giocatori, type Giocatore } from "@/lib/crapp-data"; +import type { VotoSocial } from "@/lib/badge-social"; +import type { ObiettivoSquadra } from "@/lib/obiettivi"; +import { calcolaNotifiche } from "@/lib/notifiche-smart"; + +function g(valori: Partial<Giocatore> = {}): Giocatore { + return { + ...giocatori[0]!, + presenze: 0, + mvp: 0, + mediaVoto: 0, + palloni: 0, + cacche: 0, + cacchePartita: 0, + infortuni: 0, + ritardi: 0, + streak: 0, + serieAllenamenti: 0, + seriePartite: 0, + serieConferme: 0, + ...valori, + }; +} + +const ids = (g: Giocatore, voti: VotoSocial[] = [], ob: ObiettivoSquadra[] = []) => + calcolaNotifiche(g, voti, ob).map((n) => n.id); + +// --- niente rumore: a zero solo l'invito al primo traguardo ------------------ +assert.deepEqual(ids(g()), ["quasi:mvp:1"], "nessun badge sbloccato, nessuna serie"); + +// --- badge sbloccati --------------------------------------------------------- +const conMvp = ids(g({ mvp: 1 })); +assert.ok(conMvp.includes("badge:mvp:bronzo"), "il badge appena preso viene annunciato"); +assert.ok( + ids(g({ mvp: 3 })).includes("badge:mvp:argento"), + "annuncia il grado corrente, non quelli precedenti", +); +assert.ok(!ids(g({ mvp: 3 })).includes("badge:mvp:bronzo")); + +// --- badge segreti ----------------------------------------------------------- +assert.ok(ids(g({ infortuni: 3 })).includes("segreto:s-infermeria")); +assert.ok(!ids(g({ infortuni: 2 })).includes("segreto:s-infermeria")); +const segreta = calcolaNotifiche(g({ ritardi: 5 })).find((n) => n.id === "segreto:s-ritardi")!; +assert.equal(segreta.tono, "segreto"); +assert.equal(segreta.emoji, "⏰"); +assert.equal(segreta.testo, "Forse è il momento di puntare la sveglia 10 minuti prima! 😄"); + +// --- "sei a un passo": solo entro due unità dalla soglia --------------------- +assert.ok(ids(g({ presenze: 4 })).some((id) => id.startsWith("quasi:presenze"))); +assert.ok( + !ids(g({ mvp: 5, presenze: 2 })).some((id) => id.startsWith("quasi:presenze")), + "a 3 di distanza non si notifica", +); + +// --- serie: solo sul traguardo esatto ---------------------------------------- +assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3")); +assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo"); +assert.ok(ids(g({ serieConferme: 8 })).includes("serie:conferme:8")); + +// --- obiettivi di squadra: uno solo, tra il 90% e il 99% -------------------- +const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({ + id, + titolo: `Obiettivo ${id}`, + descrizione: "", + valore, + target: 100, + unita: "%", + emoji: "🎯", + impatto: "", +}); +assert.deepEqual( + ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")), + ["obiettivo:o1:90"], + "un solo obiettivo per non fare rumore", +); +assert.equal( + ids(g(), [], [obiettivo("o1", 100)]).filter((i) => i.startsWith("obiettivo")).length, + 0, + "obiettivo già centrato: niente notifica", +); +assert.equal( + ids(g(), [], [obiettivo("o1", 50)]).filter((i) => i.startsWith("obiettivo")).length, + 0, +); + +// --- voti social ------------------------------------------------------------- +const io = g(); +const votiSocial: VotoSocial[] = [ + { match_id: "m1", categoria: "meme", votante_id: "g2", votato_id: io.id, votato_nome: io.nome }, + { match_id: "m1", categoria: "meme", votante_id: "g3", votato_id: io.id, votato_nome: io.nome }, +]; +assert.ok(ids(io, votiSocial).includes("social:meme:1")); +assert.equal( + calcolaNotifiche(io, votiSocial).find((n) => n.id === "social:meme:1")?.titolo, + "Meme della partita x1", +); +assert.ok(!ids(g({ ...io, id: "g99" }), votiSocial).some((i) => i.startsWith("social:"))); + +// --- forma delle notifiche --------------------------------------------------- +const tutte = calcolaNotifiche(g({ mvp: 5, infortuni: 3, serieAllenamenti: 3 }), votiSocial); +assert.equal(new Set(tutte.map((n) => n.id)).size, tutte.length, "id univoci: niente doppioni"); +for (const n of tutte) assert.ok(n.titolo && n.testo && n.emoji && n.tono, `${n.id} completa`); + +console.log("notifiche-smart: ok"); diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts new file mode 100644 index 0000000..8152f4a --- /dev/null +++ b/test/unit/obiettivi.test.ts @@ -0,0 +1,147 @@ +/** Check degli obiettivi di squadra: `bun test/unit/obiettivi.test.ts`. */ +import assert from "node:assert/strict"; +import { giocatori } from "@/lib/crapp-data"; +import type { Evento } from "@/lib/eventi"; +import type { VotoPagella } from "@/lib/pagelle"; +import type { MappaPresenze } from "@/lib/presenze"; +import { + contestoVuoto, + microcopyObiettivo, + obiettiviOrdinati, + obiettiviSquadra, + progressoObiettivo, + type ContestoObiettivi, + type ObiettivoSquadra, +} from "@/lib/obiettivi"; + +const evento = (id: string, data: string, tipo: Evento["tipo"]): Evento => ({ + id, + tipo, + titolo: id, + luogo: "", + data, + ora: "21:00", + note: "", + convocati: [], + campionato: false, + casa: true, + pagelleChiuse: false, +}); + +const trova = (lista: ObiettivoSquadra[], id: string) => lista.find((o) => o.id === id)!; + +// --- contesto vuoto: nessuna divisione per zero ------------------------------ +const vuoti = obiettiviSquadra(giocatori, contestoVuoto); +assert.equal(trova(vuoti, "o1").valore, 0, "nessun evento nel mese: 0%, non NaN"); +assert.equal(trova(vuoti, "o2").valore, 0); +assert.equal(trova(vuoti, "o12").valore, 0, "nessuna pagella: media 0"); +assert.ok( + vuoti.every((o) => Number.isFinite(o.valore)), + "nessun valore NaN o infinito", +); + +// --- presenze del mese (agosto 2026) e risposte ------------------------------ +const tuttiPresenti: MappaPresenze = { + a1: Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])), +}; +const ctx: ContestoObiettivi = { + eventi: [evento("a1", "2026-08-10", "allenamento")], + presenze: tuttiPresenti, + pagelle: [], +}; +assert.equal(trova(obiettiviSquadra(giocatori, ctx), "o1").valore, 100, "rosa al completo = 100%"); + +const metaRosa: MappaPresenze = { + a1: Object.fromEntries( + giocatori.map((g, i) => [g.id, i % 2 === 0 ? ("presente" as const) : ("assente" as const)]), + ), +}; +const percentuale = trova(obiettiviSquadra(giocatori, { ...ctx, presenze: metaRosa }), "o1").valore; +assert.ok(percentuale > 40 && percentuale < 60, `metà rosa presente ≈ 50%, era ${percentuale}`); + +// Il ritardo conta come presenza, il "forse" no. +const conRitardo: MappaPresenze = { a1: { g1: "ritardo", g2: "forse" } }; +assert.equal( + trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o1").valore, + Math.round((1 / giocatori.length) * 100), + "solo il ritardo conta come presente", +); +assert.equal( + trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o2").valore, + Math.round((2 / giocatori.length) * 100), + "per le risposte anche il forse conta", +); + +// Un evento fuori mese non sposta l'obiettivo mensile. +const fuoriMese: ContestoObiettivi = { + eventi: [evento("s1", "2026-09-10", "allenamento")], + presenze: { s1: { g1: "presente" } }, + pagelle: [], +}; +assert.equal(trova(obiettiviSquadra(giocatori, fuoriMese), "o1").valore, 0); + +// I compleanni non richiedono risposta. +const soloCompleanni: ContestoObiettivi = { + eventi: [evento("c1", "2026-08-03", "compleanno")], + presenze: {}, + pagelle: [], +}; +assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni), "o2").valore, 0); + +// --- somme sulla rosa -------------------------------------------------------- +const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0); +assert.equal(trova(vuoti, "o7").valore, sommaPresenze); +assert.equal( + trova(obiettiviSquadra([], contestoVuoto), "o7").valore, + 0, + "rosa vuota: nessuna presenza", +); + +const pagelle: VotoPagella[] = [ + { match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 }, + { match_id: "m1", votante_id: "g2", votato_id: "g1", voto: 8 }, +]; +const conPagelle = obiettiviSquadra(giocatori, { ...contestoVuoto, pagelle }); +assert.equal(trova(conPagelle, "o12").valore, 7.5); +assert.equal(trova(conPagelle, "o13").valore, 2, "conta i voti compilati"); + +// --- progressoObiettivo ------------------------------------------------------ +const o = (valore: number, target: number): ObiettivoSquadra => ({ + id: "x", + titolo: "t", + descrizione: "d", + valore, + target, + unita: "%", + emoji: "🎯", + impatto: "i", +}); +assert.equal(progressoObiettivo(o(0, 10)), 0); +assert.equal(progressoObiettivo(o(5, 10)), 50); +assert.equal(progressoObiettivo(o(20, 10)), 100, "il progresso non supera il 100%"); + +// --- obiettiviOrdinati: i completati vanno in fondo -------------------------- +const ordinati = obiettiviOrdinati(giocatori, contestoVuoto); +assert.equal(ordinati.length, vuoti.length, "nessun obiettivo perso nell'ordinamento"); +const percentuali = ordinati.map(progressoObiettivo); +const completati = percentuali.filter((p) => p >= 100); +assert.deepEqual( + percentuali.slice(percentuali.length - completati.length), + completati, + "i completati stanno tutti in coda", +); +const inCorso = percentuali.slice(0, percentuali.length - completati.length); +assert.deepEqual( + inCorso, + [...inCorso].sort((a, b) => b - a), + "gli altri dal più avanzato", +); + +// --- microcopy --------------------------------------------------------------- +assert.equal(microcopyObiettivo(o(10, 10)), "Obiettivo centrato: grande squadra!"); +assert.equal(microcopyObiettivo(o(95, 100)), "Ci siamo quasi: mancano 5 %."); +assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %."); +assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo."); +assert.equal(microcopyObiettivo(o(0, 100)), "Tocca a noi far partire questo obiettivo."); + +console.log("obiettivi: ok"); diff --git a/test/unit/pagelle.test.ts b/test/unit/pagelle.test.ts new file mode 100644 index 0000000..cae1b98 --- /dev/null +++ b/test/unit/pagelle.test.ts @@ -0,0 +1,64 @@ +/** Check delle pagelle: `bun test/unit/pagelle.test.ts`. */ +import assert from "node:assert/strict"; +import { + mediaSquadra, + mediePagelle, + mieiVoti, + pagellePartita, + type VotoPagella, +} from "@/lib/pagelle"; + +const voto = ( + match_id: string, + votante_id: string, + votato_id: string, + voto: number, +): VotoPagella => ({ + match_id, + votante_id, + votato_id, + voto, +}); + +const voti: VotoPagella[] = [ + voto("m1", "g1", "g2", 8), + voto("m1", "g3", "g2", 7), + voto("m1", "g2", "g1", 6), + voto("m2", "g1", "g2", 6), +]; + +// --- mediePagelle ------------------------------------------------------------ +const medie = mediePagelle(voti); +assert.deepEqual(medie["g2"], { media: 7, voti: 3 }, "media su tutte le partite"); +assert.deepEqual(medie["g1"], { media: 6, voti: 1 }); +assert.equal(medie["g99"], undefined, "chi non ha voti non compare"); +assert.deepEqual(mediePagelle([]), {}, "nessun voto: nessuna media"); + +// Arrotondamento a un decimale, senza errori di virgola mobile. +assert.equal( + mediePagelle([voto("m1", "g1", "gx", 7), voto("m1", "g2", "gx", 8)])["gx"]?.media, + 7.5, +); +assert.equal( + mediePagelle([voto("m1", "g1", "gy", 7), voto("m1", "g2", "gy", 8), voto("m1", "g3", "gy", 8)])[ + "gy" + ]?.media, + 7.7, + "7.666… diventa 7.7", +); + +// --- pagellePartita: isola la singola partita -------------------------------- +assert.deepEqual(pagellePartita(voti, "m1")["g2"], { media: 7.5, voti: 2 }); +assert.deepEqual(pagellePartita(voti, "m2")["g2"], { media: 6, voti: 1 }); +assert.deepEqual(pagellePartita(voti, "inesistente"), {}); + +// --- mieiVoti: cosa ho già votato in questa partita -------------------------- +assert.deepEqual(mieiVoti(voti, "m1", "g1"), { g2: 8 }); +assert.deepEqual(mieiVoti(voti, "m1", "g2"), { g1: 6 }); +assert.deepEqual(mieiVoti(voti, "m1", "g99"), {}, "chi non ha votato non ha voti"); + +// --- mediaSquadra ------------------------------------------------------------ +assert.equal(mediaSquadra(voti), 6.8, "(8+7+6+6)/4 = 6.75 → 6.8"); +assert.equal(mediaSquadra([]), 0, "nessun voto: media zero, non NaN"); + +console.log("pagelle: ok"); diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts new file mode 100644 index 0000000..b14e20b --- /dev/null +++ b/test/unit/palloni-core.test.ts @@ -0,0 +1,98 @@ +/** Check dei turni palloni: `bun src/lib/palloni-core.test.ts`. */ +import assert from "node:assert/strict"; +import { giocatori } from "@/lib/crapp-data"; +import type { Evento } from "@/lib/eventi"; +import { + completaTurni, + conteggioTurni, + eventiDelGiorno, + eventiPalloni, + eventoPrecedente, + eventoSuccessivo, + oggiISO, +} from "@/lib/palloni-core"; + +const evento = (id: string, data: string, tipo: Evento["tipo"] = "allenamento"): Evento => ({ + id, + tipo, + titolo: `Evento ${id}`, + luogo: "PalaCRAP", + data, + ora: "21:00", + note: "", + convocati: [], + campionato: false, + casa: true, + pagelleChiuse: false, +}); + +const eventi: Evento[] = [ + evento("e3", "2026-09-10"), + evento("e1", "2026-09-01"), + evento("compleanno", "2026-09-05", "compleanno"), + evento("e2", "2026-09-05", "partita"), +]; + +// --- eventiPalloni: ordina per data e scarta i compleanni --------------------- +const ordinati = eventiPalloni(eventi); +assert.deepEqual( + ordinati.map((e) => e.id), + ["e1", "e2", "e3"], + "compleanni esclusi e ordine cronologico", +); +assert.equal(eventi[0]!.id, "e3", "l'array in ingresso non viene modificato"); + +// --- eventoPrecedente / eventoSuccessivo ------------------------------------- +assert.equal(eventoPrecedente(eventi, "e2")?.id, "e1"); +assert.equal(eventoPrecedente(eventi, "e1"), undefined, "il primo non ha precedente"); +assert.equal(eventoSuccessivo(eventi, "e2")?.id, "e3"); +assert.equal(eventoSuccessivo(eventi, "e3"), undefined, "l'ultimo non ha successivo"); +assert.equal(eventoPrecedente(eventi, "inesistente"), undefined); + +// --- eventiDelGiorno --------------------------------------------------------- +assert.deepEqual( + eventiDelGiorno(eventi, "2026-09-05").map((e) => e.id), + ["e2"], + "il compleanno dello stesso giorno non richiede palloni", +); + +// --- completaTurni: assegna i mancanti, rispetta quelli già decisi ----------- +const turni = completaTurni({ e2: "g5" }, eventi); +assert.equal(turni["e2"], "g5", "il turno già assegnato non viene toccato"); +assert.equal(Object.keys(turni).length, 3, "tutti gli eventi hanno un incaricato"); +assert.ok( + giocatori.some((g) => g.id === turni["e1"]), + "assegna a un giocatore reale", +); +assert.notEqual(turni["e1"], turni["e3"], "non tocca due volte di fila alla stessa persona"); + +const molti = Array.from({ length: giocatori.length + 2 }, (_, i) => + evento(`x${i}`, `2026-10-${String(i + 1).padStart(2, "0")}`), +); +const rotazione = conteggioTurni(completaTurni({}, molti)); +const carichi = Object.values(rotazione); +assert.equal( + Math.max(...carichi) - Math.min(...carichi), + 1, + "su un giro completo il carico resta bilanciato", +); +assert.equal( + Object.keys(rotazione).length, + giocatori.length, + "nessuno viene saltato prima che tutti abbiano fatto un turno", +); + +// Un turno salvato per un giocatore non più in rosa non deve rompere il conteggio. +const conFantasma = completaTurni({ e1: "gXX" }, eventi); +assert.equal(conFantasma["e1"], "gXX", "il turno storico resta com'è"); +assert.equal(Object.keys(conFantasma).length, 3); + +// --- conteggioTurni ---------------------------------------------------------- +assert.deepEqual(conteggioTurni({ a: "g1", b: "g1", c: "g2" }), { g1: 2, g2: 1 }); +assert.deepEqual(conteggioTurni({}), {}); + +// --- oggiISO ----------------------------------------------------------------- +assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/); +assert.equal(oggiISO(), new Date().toLocaleDateString("sv-SE"), "data locale, non UTC"); + +console.log("palloni-core: ok"); diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts new file mode 100644 index 0000000..d18f209 --- /dev/null +++ b/test/unit/profili-core.test.ts @@ -0,0 +1,190 @@ +/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */ +import assert from "node:assert/strict"; +import { + aRigaProfilo, + completamento, + csvTesseramento, + daRigaProfilo, + sezioniComplete, + statoScadenza, + type Profilo, +} from "@/lib/profili-core"; +import { + dividiNome, + numeroGiaUsato, + rosaFallback, + slotDi, + slotLiberi, + validaDatiSquadra, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; + +const vuoto: Profilo = { + giocatoreId: "g1", + dataNascita: null, + luogoNascita: null, + indirizzo: null, + telefono: null, + email: null, + documentoTipo: null, + documentoNumero: null, + documentoRilasciatoDa: null, + documentoEmissione: null, + documentoScadenza: null, + documentoFrontePath: null, + documentoRetroPath: null, + certificatoScadenza: null, + certificatoPath: null, + fotoPath: null, +}; + +const completo: Profilo = { + ...vuoto, + dataNascita: "1995-05-01", + luogoNascita: "Bologna", + indirizzo: "Via Roma 1", + telefono: "3331234567", + email: "ivan@example.com", + documentoTipo: "Carta d'identità", + documentoNumero: "CA12345", + documentoRilasciatoDa: "Comune di Bologna", + documentoEmissione: "2020-01-01", + documentoScadenza: "2030-01-01", + documentoFrontePath: "g1/documento-fronte.jpg", + documentoRetroPath: "g1/documento-retro.jpg", + certificatoScadenza: "2027-06-30", + certificatoPath: "g1/certificato.pdf", + fotoPath: "g1/foto.jpg", +}; + +// --- completamento ----------------------------------------------------------- +assert.equal(completamento(null), 0, "profilo inesistente = 0%"); +assert.equal(completamento(vuoto), 0); +assert.equal(completamento(completo), 100, "tutte le sezioni piene = 100%"); +assert.equal(completamento({ ...completo, fotoPath: null }), 90, "la foto pesa 10"); +assert.equal(completamento({ ...completo, certificatoPath: null }), 70, "il certificato pesa 30"); +assert.equal( + completamento({ ...completo, email: null }), + 70, + "i dati personali sono completi solo tutti insieme", +); + +// I metadati senza file (o viceversa) non contano come sezione completa. +assert.equal(sezioniComplete({ ...completo, certificatoScadenza: null }).certificato, false); +assert.equal( + sezioniComplete({ ...completo, documentoRetroPath: null }).documento, + false, + "il documento vale solo con fronte e retro", +); +assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false); + +// --- aRigaProfilo ------------------------------------------------------------ +const riga = aRigaProfilo({ ...completo, luogoNascita: " ", telefono: " 333 " }); +assert.equal(riga.luogo_nascita, null, "i campi solo-spazi tornano NULL, non stringa vuota"); +assert.equal(riga.telefono, "333", "il resto viene ripulito ai bordi"); +assert.equal(riga.documento_fronte_path, "g1/documento-fronte.jpg"); +assert.deepEqual( + daRigaProfilo(aRigaProfilo(completo)), + completo, + "modello -> riga -> modello non perde niente", +); + +// --- statoScadenza ----------------------------------------------------------- +const oggi = "2026-08-30"; +assert.equal(statoScadenza(null, null, oggi), "mancante"); +assert.equal(statoScadenza("2027-01-01", null, oggi), "mancante", "senza file non vale"); +assert.equal(statoScadenza(null, "g1/cert.pdf", oggi), "mancante", "senza data non vale"); +assert.equal(statoScadenza("2026-08-29", "g1/cert.pdf", oggi), "scaduto"); +assert.equal( + statoScadenza("2026-08-30", "g1/cert.pdf", oggi), + "valido", + "scade oggi = ancora valido", +); +assert.equal(statoScadenza("2026-12-31", "g1/cert.pdf", oggi), "valido"); + +// --- csvTesseramento --------------------------------------------------------- +const squadra: GiocatoreSquadra[] = [ + { + id: "g1", + nome: "Ivan", + cognome: "Cacciari", + numero: 23, + ruolo: "Banda", + authUserId: null, + attivo: true, + }, + { + id: "g2", + nome: "Anna", + cognome: 'De "Rossi"', + numero: 7, + ruolo: "Libero", + authUserId: "u2", + attivo: true, + }, +]; +const csv = csvTesseramento(squadra, { g1: completo }); +const righe = csv.split("\n"); +assert.equal(righe.length, 3, "intestazione + un giocatore per riga"); +assert.equal(righe[0]?.split(";").length, 12, "i 12 campi richiesti dal CSI"); +assert.match(righe[1] ?? "", /^Ivan;Cacciari;1995-05-01;Bologna/); +assert.match( + righe[2] ?? "", + /^Anna;"De ""Rossi""";;;/, + "chi non ha profilo esce con i campi vuoti", +); + +// --- anagrafica squadra ------------------------------------------------------ +assert.deepEqual(dividiNome("Ivan Cacciari"), { nome: "Ivan", cognome: "Cacciari" }); +assert.deepEqual( + dividiNome("Carlo Di Castelnuovo"), + { nome: "Carlo", cognome: "Di Castelnuovo" }, + "il cognome composto resta intero", +); +assert.deepEqual(dividiNome("Ivan"), { nome: "Ivan", cognome: "" }); + +assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot"); +assert.equal(slotDi(squadra, "u2")?.id, "g2"); +assert.equal(slotDi(squadra, "sconosciuto"), null); +assert.deepEqual( + slotLiberi(squadra).map((g) => g.id), + ["g1"], + "uno slot già collegato non è più libero", +); +assert.deepEqual( + slotLiberi([...squadra, { ...squadra[0]!, id: "g3", attivo: false }]).map((g) => g.id), + ["g1"], + "i giocatori non attivi restano fuori", +); + +// --- dati squadra modificabili dall'admin (DD-017) --------------------------- +const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" }; +assert.equal(validaDatiSquadra(datiOk), null); +assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i); +assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i); +assert.match(validaDatiSquadra({ ...datiOk, ruolo: " " }) ?? "", /ruolo/i); +assert.match( + validaDatiSquadra({ ...datiOk, numero: 0 }) ?? "", + /numero/i, + "il database rifiuta numero <= 0: meglio dirlo prima", +); +assert.match(validaDatiSquadra({ ...datiOk, numero: -3 }) ?? "", /numero/i); +assert.match(validaDatiSquadra({ ...datiOk, numero: 1.5 }) ?? "", /numero/i); + +assert.equal(numeroGiaUsato(squadra, "g1", 7), true, "il 7 è di g2"); +assert.equal(numeroGiaUsato(squadra, "g2", 7), false, "il proprio numero non è un conflitto"); +assert.equal(numeroGiaUsato(squadra, "g1", 99), false); +assert.equal( + numeroGiaUsato([{ ...squadra[1]!, attivo: false }], "g1", 7), + false, + "chi non è più in rosa non blocca il numero", +); + +const fallback = rosaFallback(); +assert.ok(fallback.length > 0, "il fallback da crapp-data non è mai vuoto"); +assert.ok( + fallback.every((g) => g.authUserId === null && g.attivo), + "il fallback non può collegare account", +); + +console.log("profili-core: ok"); diff --git a/test/unit/scout-export.test.ts b/test/unit/scout-export.test.ts new file mode 100644 index 0000000..bc0ba52 --- /dev/null +++ b/test/unit/scout-export.test.ts @@ -0,0 +1,81 @@ +/** Check dell'export CSV dello scout: `bun test/unit/scout-export.test.ts`. */ +import assert from "node:assert/strict"; +import { giocatori } from "@/lib/crapp-data"; +import { csvScoutMatch } from "@/lib/scout-export"; +import type { Azione, ScoutMatch } from "@/lib/scout-store"; + +const g1 = giocatori.find((g) => g.id === "g1")!; +const g2 = giocatori.find((g) => g.id === "g2")!; + +const a = (tipo: Azione["tipo"], giocatoreId?: string, set = 1): Azione => ({ + id: `${tipo}-${set}-${giocatoreId ?? "team"}`, + tipo, + ...(giocatoreId ? { giocatoreId } : {}), + set, + ts: 0, +}); + +const match: ScoutMatch = { + id: "m1", + data: "2026-09-01", + avversario: "Volley; Bologna", + casa: true, + setNostri: 3, + setLoro: 1, + parziali: [ + [25, 19], + [23, 25], + [25, 21], + [25, 18], + ], + mvp: g1.nome, + azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")], +}; + +const csv = csvScoutMatch(match); +const righe = csv.split("\n"); + +// --- intestazione ------------------------------------------------------------ +assert.equal( + righe[0], + 'Partita;CRAP Volley;vs;"Volley; Bologna"', + "il ; nel nome va tra virgolette", +); +assert.equal(righe[1], "Data;2026-09-01;Set;3-1"); +assert.ok(csv.includes("Set;Parziale nostro;Parziale loro")); +assert.ok(csv.includes("1;25;19"), "primo parziale"); +assert.ok(csv.includes("4;25;18"), "ultimo parziale"); + +// --- totali per giocatore ---------------------------------------------------- +assert.ok(csv.includes("Numero;Giocatore;Ruolo;Punti;Ace;Muri;Errori")); +assert.ok( + csv.includes(`${g1.numero};${g1.nome};${g1.ruolo};2;1;0;0`), + "attacco + ace = 2 punti di cui 1 ace", +); +assert.ok(csv.includes(`${g2.numero};${g2.nome};${g2.ruolo};0;0;0;1`)); +assert.ok( + !righe.some((r) => r.startsWith(`${giocatori[2]!.numero};${giocatori[2]!.nome};`)), + "chi non ha azioni non compare tra i totali", +); + +// --- dettaglio azioni -------------------------------------------------------- +assert.ok(csv.includes("Set;Giocatore;Azione")); +assert.ok(csv.includes(`1;${g1.nome};Attacco vincente`) || csv.includes(`1;${g1.nome};`)); +assert.ok( + righe.some((r) => r.startsWith("1;") && r.includes("—")), + "l'azione senza giocatore usa il trattino", +); + +// --- trasferta e match vuoto ------------------------------------------------- +const fuori = csvScoutMatch({ ...match, casa: false, avversario: "Ospiti" }); +assert.equal(fuori.split("\n")[0], "Partita;Ospiti;vs;CRAP Volley", "in trasferta si invertono"); + +const vuoto = csvScoutMatch({ ...match, azioni: [], parziali: [] }); +assert.ok(vuoto.includes("Data;2026-09-01"), "un match senza azioni resta esportabile"); +assert.ok(!vuoto.includes(`;${g1.nome};`), "nessun totale senza azioni"); + +// Le virgolette nel testo vanno raddoppiate (regola CSV). +const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' }); +assert.ok(conVirgolette.includes('"Team ""X"""')); + +console.log("scout-export: ok"); diff --git a/test/unit/scout-live.test.ts b/test/unit/scout-live.test.ts new file mode 100644 index 0000000..6b31ed5 --- /dev/null +++ b/test/unit/scout-live.test.ts @@ -0,0 +1,58 @@ +/** Check della sessione Scout Live: `bun test/unit/scout-live.test.ts`. */ +import assert from "node:assert/strict"; +import type { Evento } from "@/lib/eventi"; +import { + SCADENZA_MINUTI, + dataOggi, + partitaDiOggi, + sessioneScaduta, + type SessioneScout, +} from "@/lib/scout-live"; + +const evento = (id: string, data: string, tipo: Evento["tipo"]): Evento => ({ + id, + tipo, + titolo: id, + luogo: "", + data, + ora: "21:00", + note: "", + convocati: [], + campionato: true, + casa: true, + pagelleChiuse: false, +}); + +// --- dataOggi ---------------------------------------------------------------- +assert.match(dataOggi(), /^\d{4}-\d{2}-\d{2}$/); +assert.equal(dataOggi(), new Date().toLocaleDateString("sv-SE"), "data locale, non UTC"); + +// --- partitaDiOggi ----------------------------------------------------------- +const eventi = [ + evento("a1", "2026-09-01", "allenamento"), + evento("p1", "2026-09-01", "partita"), + evento("p2", "2026-09-02", "partita"), +]; +assert.equal(partitaDiOggi(eventi, "2026-09-01")?.id, "p1", "l'allenamento non si scoutizza"); +assert.equal(partitaDiOggi(eventi, "2026-09-03"), null, "nessuna partita oggi"); +assert.equal(partitaDiOggi([], "2026-09-01"), null); + +// --- sessioneScaduta: libera il tavolo dopo SCADENZA_MINUTI ------------------ +const sessione = (minutiFa: number): SessioneScout => ({ + evento_id: "p1", + giocatore_id: "g1", + giocatore_nome: "Tizio", + aggiornato_il: new Date(Date.now() - minutiFa * 60_000).toISOString(), +}); + +assert.equal(sessioneScaduta(null), true, "nessuna sessione = tavolo libero"); +assert.equal(sessioneScaduta(sessione(0)), false, "appena aggiornata"); +assert.equal(sessioneScaduta(sessione(SCADENZA_MINUTI - 1)), false, "dentro la finestra"); +assert.equal(sessioneScaduta(sessione(SCADENZA_MINUTI + 1)), true, "oltre la finestra"); +assert.equal( + sessioneScaduta({ ...sessione(0), aggiornato_il: "data-non-valida" }), + true, + "timestamp illeggibile: meglio liberare la sessione che bloccarla", +); + +console.log("scout-live: ok"); diff --git a/test/unit/scout-store.test.ts b/test/unit/scout-store.test.ts new file mode 100644 index 0000000..3f2cda9 --- /dev/null +++ b/test/unit/scout-store.test.ts @@ -0,0 +1,120 @@ +/** Check dello scout: `bun test/unit/scout-store.test.ts`. */ +import assert from "node:assert/strict"; +import { classifica, giocatori } from "@/lib/crapp-data"; +import { + azioniMeta, + classificaConScout, + giocatoriConScout, + totaliPerGiocatore, + totaliSquadra, + type Azione, + type ScoutMatch, +} from "@/lib/scout-store"; + +const a = (tipo: Azione["tipo"], giocatoreId?: string, set = 1): Azione => ({ + id: `${tipo}-${giocatoreId ?? "team"}-${set}-${Math.random()}`, + tipo, + ...(giocatoreId ? { giocatoreId } : {}), + set, + ts: 0, +}); + +const match = ( + id: string, + setNostri: number, + setLoro: number, + azioni: Azione[] = [], +): ScoutMatch => ({ + id, + data: "2026-09-01", + avversario: "Avversari", + casa: true, + setNostri, + setLoro, + parziali: [], + mvp: "", + azioni, +}); + +// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------ +const azioni = [ + a("attacco", "g1"), + a("ace", "g1"), + a("muro", "g1"), + a("errore", "g1"), + a("attacco", "g2"), + a("punto_avv"), + a("errore_avv"), +]; +const totali = totaliPerGiocatore(azioni); +assert.deepEqual(totali.get("g1"), { punti: 3, ace: 1, muri: 1, errori: 1 }); +assert.deepEqual(totali.get("g2"), { punti: 1, ace: 0, muri: 0, errori: 0 }); +assert.equal(totali.size, 2, "le azioni senza giocatore non creano righe"); +assert.equal(totaliPerGiocatore([]).size, 0); + +// --- totaliSquadra ----------------------------------------------------------- +assert.deepEqual(totaliSquadra([match("m1", 3, 0, azioni), match("m2", 3, 1, azioni)]), { + punti: 8, + ace: 2, + muri: 2, + errori: 2, +}); +assert.deepEqual(totaliSquadra([]), { punti: 0, ace: 0, muri: 0, errori: 0 }); + +// --- giocatoriConScout: presenze e MVP, nessuna statistica offensiva --------- +const base = giocatori.find((g) => g.id === "g1")!; +const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome }); +const dopo = conScout.find((g) => g.id === "g1")!; +assert.equal(dopo.presenze, base.presenze + 1, "chi ha azioni risulta presente"); +assert.equal(dopo.mvp, base.mvp + 1, "l'MVP eletto viene sommato"); +assert.equal(dopo.totaliEventi, base.totaliEventi + 1); + +const assente = conScout.find((g) => g.id === "g3")!; +const baseAssente = giocatori.find((g) => g.id === "g3")!; +assert.equal(assente.presenze, baseAssente.presenze, "chi non ha azioni non guadagna presenze"); +assert.equal(assente.totaliEventi, baseAssente.totaliEventi + 1, "l'evento conta per tutti"); + +assert.deepEqual(giocatoriConScout([]), giocatori, "senza partite la rosa resta invariata"); + +// --- classificaConScout: 3 punti netti, 2 al tie-break vinto, 1 al perso ----- +assert.deepEqual(classificaConScout([]), classifica, "senza scout resta la classifica di partenza"); + +const noi = "CRAP Volley"; +const rigaBase = classifica.find((r) => r.squadra === noi)!; +const puntiDopo = (m: ScoutMatch) => + classificaConScout([m]).find((r) => r.squadra === noi)!.punti - rigaBase.punti; + +assert.equal(puntiDopo(match("v1", 3, 0)), 3, "3-0 vale 3 punti"); +assert.equal(puntiDopo(match("v2", 3, 1)), 3, "3-1 vale 3 punti"); +assert.equal(puntiDopo(match("v3", 3, 2)), 2, "3-2 vale 2 punti"); +assert.equal(puntiDopo(match("p1", 2, 3)), 1, "2-3 vale 1 punto"); +assert.equal(puntiDopo(match("p2", 0, 3)), 0, "0-3 non vale punti"); + +const aggiornata = classificaConScout([match("v1", 3, 1)]); +const nostra = aggiornata.find((r) => r.squadra === noi)!; +assert.equal(nostra.giocate, rigaBase.giocate + 1); +assert.equal(nostra.vinte, rigaBase.vinte + 1); +assert.equal(nostra.setFatti, rigaBase.setFatti + 3); +assert.equal(nostra.setSubiti, rigaBase.setSubiti + 1); +assert.deepEqual( + aggiornata.map((r) => r.pos), + aggiornata.map((_, i) => i + 1), + "le posizioni vengono rinumerate dopo il riordino", +); +assert.deepEqual( + aggiornata.map((r) => r.punti), + [...aggiornata.map((r) => r.punti)].sort((x, y) => y - x), + "ordinata per punti decrescenti", +); +assert.equal( + aggiornata.filter((r) => r.squadra !== noi).length, + classifica.length - 1, + "le altre squadre restano invariate", +); + +// --- metadati azioni --------------------------------------------------------- +for (const [tipo, meta] of Object.entries(azioniMeta)) { + assert.ok(meta.label && meta.short, `${tipo}: etichette presenti`); +} + +console.log("scout-store: ok"); diff --git a/test/unit/serie.test.ts b/test/unit/serie.test.ts new file mode 100644 index 0000000..521c375 --- /dev/null +++ b/test/unit/serie.test.ts @@ -0,0 +1,60 @@ +/** Check delle serie consecutive: `bun test/unit/serie.test.ts`. */ +import assert from "node:assert/strict"; +import { giocatori, type Giocatore } from "@/lib/crapp-data"; +import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie"; + +function g(valori: Partial<Giocatore> = {}): Giocatore { + return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, serieConferme: 0, ...valori }; +} + +const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!; + +// --- aggiornaSerie: si azzera solo la serie non onorata ----------------------- +assert.equal(aggiornaSerie(4, true), 5); +assert.equal(aggiornaSerie(4, false), 0, "un buco azzera la serie"); +assert.equal(aggiornaSerie(0, true), 1, "si riparte da uno"); + +// --- statoSerie: traguardi 3 / 6 / 10 / 15 ----------------------------------- +const partenza = statoSerie(allenamenti, g()); +assert.deepEqual( + [partenza.valore, partenza.prossimo, partenza.manca, partenza.progresso], + [0, 3, 3, 0], +); +assert.equal(partenza.messaggio, "Serie allenamenti azzerata: riparti dal prossimo."); + +const aDue = statoSerie(allenamenti, g({ serieAllenamenti: 2 })); +assert.deepEqual([aDue.prossimo, aDue.manca, aDue.progresso], [3, 1, 67]); +assert.equal(aDue.messaggio, "Manca solo una volta al prossimo traguardo!"); + +const sulTraguardo = statoSerie(allenamenti, g({ serieAllenamenti: 3 })); +assert.equal(sulTraguardo.prossimo, 6, "raggiunto un traguardo si punta al successivo"); +assert.equal(sulTraguardo.progresso, 50); + +const veterano = statoSerie(allenamenti, g({ serieAllenamenti: 7 })); +assert.equal(veterano.messaggio, "Che continuità: ancora 3 e sali di livello."); + +const fuoriScala = statoSerie(allenamenti, g({ serieAllenamenti: 20 })); +assert.deepEqual([fuoriScala.prossimo, fuoriScala.manca, fuoriScala.progresso], [null, 0, 100]); +assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!"); + +// --- serieGiocatore / serieMigliore ------------------------------------------ +const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 })); +assert.equal(tutte.length, serieDefs.length, "tre serie indipendenti"); +assert.deepEqual( + tutte.map((s) => s.valore), + [4, 1, 9], + "ogni serie legge il proprio contatore", +); + +const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 })); +assert.equal(migliore.def.tipo, "conferme", "in home si mostra la serie più lunga"); +assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare"); + +// --- invarianti sulle definizioni -------------------------------------------- +for (const def of serieDefs) { + const ordinati = [...def.traguardi].sort((a, b) => a - b); + assert.deepEqual(def.traguardi, ordinati, `${def.tipo}: traguardi crescenti`); + assert.ok(def.traguardi.length > 0 && def.label && def.descrizione); +} + +console.log("serie: ok"); diff --git a/tsconfig.json b/tsconfig.json index a522d21..397e5e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "eslint.config.js"], + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "vite.config.ts", "eslint.config.js"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx",