From 6dc63d9250f6beae5f07d529a2f22b8a88e29774 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Fri, 7 Aug 2026 12:23:27 +0200 Subject: [PATCH 01/26] Test branch develop --- src/routes/benvenuto.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index 1140b9b..865e66f 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -7,12 +7,12 @@ import { impostaGiocatore, 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.", }, - { property: "og:title", content: "Benvenuto — CrAPP" }, + { property: "og:title", content: "Benvenuto — CrAPP DEVELOP" }, { property: "og:description", content: "Seleziona il tuo profilo giocatore per iniziare.", @@ -36,7 +36,7 @@ function Benvenuto() {

- Benvenuto in CrAPP TEST + Benvenuto in CrAPP DEVELOP

Seleziona chi sei per personalizzare l'app. From 7a2b26ec89de8f71e4141175bf7ab90965921769 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Fri, 7 Aug 2026 15:36:02 +0200 Subject: [PATCH 02/26] Add project documentation and AI development guidelines --- .cursor/architecture-summary.md | 37 ++++ .cursor/coding-style.md | 12 ++ .cursor/crapp-context.md | 22 +++ .cursor/development-workflow.md | 11 ++ .cursor/project-rules.md | 29 +++ AGENTS.md | 238 +++++++++++++++++++++++-- PROJECT_STATE.md | 50 ++++++ README.md | 185 +++++++++----------- docs/ARCHITECTURE.md | 71 ++++++++ docs/CHANGELOG.md | 30 ++++ docs/DATABASE.md | 159 +++++++++++++++++ docs/ROADMAP.md | 46 +++++ docs/TODO.md | 29 +++ docs/VISION.md | 38 ++++ docs/modules/profilo-giocatore.md | 281 ++++++++++++++++++++++++++++++ 15 files changed, 1124 insertions(+), 114 deletions(-) create mode 100644 .cursor/architecture-summary.md create mode 100644 .cursor/coding-style.md create mode 100644 .cursor/crapp-context.md create mode 100644 .cursor/development-workflow.md create mode 100644 .cursor/project-rules.md create mode 100644 PROJECT_STATE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CHANGELOG.md create mode 100644 docs/DATABASE.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/TODO.md create mode 100644 docs/VISION.md create mode 100644 docs/modules/profilo-giocatore.md diff --git a/.cursor/architecture-summary.md b/.cursor/architecture-summary.md new file mode 100644 index 0000000..e37235c --- /dev/null +++ b/.cursor/architecture-summary.md @@ -0,0 +1,37 @@ +# Architecture Summary + +Frontend + +- React +- TanStack Start +- Tailwind +- TypeScript + +Backend + +- Supabase + +Hosting + +- Vercel + +Repository + +- GitHub + +Branch + +- main +- develop + +Documentazione + +- docs/ + +Database + +- Supabase + +Storage + +- Supabase Storage \ No newline at end of file diff --git a/.cursor/coding-style.md b/.cursor/coding-style.md new file mode 100644 index 0000000..a695ac0 --- /dev/null +++ b/.cursor/coding-style.md @@ -0,0 +1,12 @@ +# Coding Style + +Preferenze del progetto. + +- Utilizzare TypeScript. +- Preferire funzioni piccole. +- Evitare duplicazione di codice. +- Utilizzare componenti React riutilizzabili. +- Commentare solamente il codice realmente complesso. +- Preferire nomi descrittivi. +- Non introdurre librerie senza reale necessità. +- Mantenere la struttura esistente del progetto. \ No newline at end of file diff --git a/.cursor/crapp-context.md b/.cursor/crapp-context.md new file mode 100644 index 0000000..d03bd8a --- /dev/null +++ b/.cursor/crapp-context.md @@ -0,0 +1,22 @@ +# CrAPP Context + +CrAPP è una Progressive Web App dedicata alla gestione di una squadra di pallavolo amatoriale. + +L'obiettivo principale NON è solamente registrare dati. + +L'obiettivo è ridurre il lavoro amministrativo degli amministratori e aumentare il coinvolgimento dei giocatori attraverso gamification, statistiche e strumenti intelligenti. + +Quando implementi nuove funzionalità: + +- privilegia semplicità +- mantieni la coerenza dell'interfaccia +- evita duplicazioni +- leggi sempre la documentazione presente in `docs/` + +Prima di scrivere codice consulta: + +- README +- ROADMAP +- DATABASE +- ARCHITECTURE +- il modulo interessato in `docs/modules` \ No newline at end of file diff --git a/.cursor/development-workflow.md b/.cursor/development-workflow.md new file mode 100644 index 0000000..fe70802 --- /dev/null +++ b/.cursor/development-workflow.md @@ -0,0 +1,11 @@ +# Development Workflow + +Ogni nuova funzionalità segue questo flusso. + +1. Discussione funzionale. +2. Documento in `docs/modules`. +3. Progettazione database. +4. Implementazione su branch `develop`. +5. Test. +6. Merge su `main`. +7. Deploy automatico tramite Vercel. \ No newline at end of file diff --git a/.cursor/project-rules.md b/.cursor/project-rules.md new file mode 100644 index 0000000..cbd9c92 --- /dev/null +++ b/.cursor/project-rules.md @@ -0,0 +1,29 @@ +# Project Rules + +Queste regole devono essere rispettate per qualsiasi modifica al progetto. + +## Regole generali + +- Non modificare il branch `main` direttamente. +- Tutte le nuove funzionalità vengono sviluppate su `develop`. +- Prima di implementare una funzionalità leggere sempre la documentazione presente in `docs/`. +- Non creare codice duplicato. +- Riutilizzare sempre componenti già esistenti quando possibile. +- Mantenere uno stile coerente con il progetto. + +## Database + +- Non modificare il database senza creare una nuova migration Supabase. +- Non eliminare tabelle esistenti senza esplicita richiesta. +- Preferire nuove tabelle rispetto all'aggiunta di molte colonne quando il modulo è indipendente. + +## Componenti + +- Preferire componenti piccoli e riutilizzabili. +- Evitare componenti con responsabilità multiple. + +## Documentazione + +Ogni nuova funzionalità deve essere documentata prima dell'implementazione. + +La documentazione tecnica si trova nella cartella `docs/`. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 36eb109..650a686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,228 @@ - -> [!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. - +# AGENTS.md + +# 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 +2. docs/VISION.md +3. docs/ROADMAP.md +4. docs/ARCHITECTURE.md +5. docs/DATABASE.md +6. docs/TODO.md +7. il documento interessato in docs/modules/ + +Non implementare funzionalità non documentate. + +--- + +# Workflow di sviluppo + +Ogni nuova funzionalità segue sempre questo processo. + +Idea + +↓ + +Progettazione + +↓ + +Documentazione + +↓ + +Database + +↓ + +Implementazione + +↓ + +Test + +↓ + +Merge su main + +↓ + +Deploy automatico + +--- + +# Git + +Il repository utilizza due branch principali. + +## main + +Versione stabile. + +Qualsiasi modifica deve mantenere l'app perfettamente funzionante. + +## develop + +Branch utilizzato per lo sviluppo delle nuove funzionalità. + +Tutte le nuove implementazioni devono essere realizzate qui. + +--- + +# 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. + +Fare sempre riferimento a: + +docs/DATABASE.md + +--- + +# Componenti + +Preferire: + +- componenti piccoli; +- componenti riutilizzabili; +- responsabilità singola; +- codice semplice da mantenere. + +Evitare duplicazioni. + +--- + +# 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 +- CHANGELOG.md +- TODO.md +- DATABASE.md (se il database cambia) +--- + +# Struttura della documentazione + +La cartella `docs/` rappresenta la documentazione ufficiale del progetto. + +## Documenti principali + +- README.md → panoramica del progetto +- VISION.md → obiettivi e filosofia +- ROADMAP.md → evoluzione prevista +- ARCHITECTURE.md → architettura tecnica +- DATABASE.md → struttura del database +- CHANGELOG.md → cronologia delle modifiche +- TODO.md → attività pianificate + +## 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. +--- + +# 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. + +L'AI deve: + +- spiegare le modifiche importanti; +- mantenere compatibilità con il codice esistente; +- privilegiare la semplicità; +- riutilizzare i componenti esistenti. + +--- + +# 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? + +Se almeno una risposta è negativa, rivalutare la soluzione proposta. \ No newline at end of file diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md new file mode 100644 index 0000000..4a91cbb --- /dev/null +++ b/PROJECT_STATE.md @@ -0,0 +1,50 @@ +# Project State + +Ultimo aggiornamento: 07/08/2026 + +## Stato generale + +Fase corrente: + +Progettazione dell'area Profilo Giocatore. + +--- + +## Infrastruttura + +- Sviluppo locale configurato +- GitHub configurato +- Vercel configurato +- Branch develop operativo + +--- + +## Moduli completati + +- Squadra +- Presenze +- Badge +- Scout Live +- Pagelle +- MVP +- Notifiche + +--- + +## Modulo in progettazione + +Profilo Giocatore + +--- + +## Prossimo sviluppo + +Implementazione del modulo Profilo Giocatore. + +--- + +## Note + +Il progetto segue una metodologia document-first. + +Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata. \ No newline at end of file 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..5335322 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,71 @@ +# Architettura del progetto + +## Frontend + +- React 19 +- TypeScript +- TanStack Start +- Vite +- Tailwind CSS +- Radix UI + +--- + +## Backend + +- Supabase + +--- + +## Hosting + +- Vercel + +--- + +## Versionamento + +- Git +- GitHub + +--- + +## Branch + +- main → Produzione +- develop → Sviluppo + +--- + +## Struttura del progetto + +src/ + +- components/ +- routes/ +- lib/ +- integrations/ +- hooks/ +- assets/ + +supabase/ + +docs/ + +--- + +## Flusso di sviluppo + +develop + +↓ + +Test + +↓ + +Merge su main + +↓ + +Deploy automatico su Vercel \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..5374edd --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +Tutte le modifiche significative del progetto vengono registrate in questo documento. + +--- + +## Versione attuale + +### Infrastruttura + +- Migrazione completa da Lovable a sviluppo locale. +- Configurazione Git. +- Repository GitHub indipendente. +- Deploy automatico tramite Vercel. +- Branch main e develop. + +--- + +## Funzionalità implementate + +- Gestione squadra +- Calendario +- Presenze +- Scout Live +- Badge +- Obiettivi di squadra +- Pagelle +- Badge social +- Serie di presenze +- Notifiche intelligenti \ No newline at end of file diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..738d2e6 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,159 @@ +# Database CrAPP + +## Obiettivo + +Questo documento descrive la struttura del database Supabase e il ruolo di ogni tabella. + +--- + +# Utenti + +## giocatori + +Contiene l'anagrafica dei giocatori. + +Utilizzato da: + +- Squadra +- Profili +- Presenze +- Scout +- Badge +- Pagelle + +--- + +## user_roles + +Definisce i ruoli applicativi. + +Esempi: + +- amministratore +- giocatore + +--- + +# Eventi + +## eventi + +Calendario generale. + +Comprende: + +- allenamenti +- partite +- eventi della squadra + +--- + +## eventi_app + +Eventi gestionali utilizzati dall'app. + +--- + +# Presenze + +## presenze + +Gestisce le presenze agli eventi. + +--- + +## risposte_presenze + +Memorizza le risposte dei giocatori. + +--- + +# Scout + +## scout_sessioni + +Sessioni di Scout Live. + +Una sessione corrisponde ad una partita. + +--- + +## scout_live + +Eventi registrati durante lo Scout Live. + +Serve esclusivamente per statistiche di squadra. + +--- + +# Votazioni + +## mvp_voti + +Voti MVP assegnati a fine partita. + +--- + +## pagelle_voti + +Voti anonimi assegnati ai giocatori. + +Utilizzati per il voto medio. + +--- + +## badge_social_voti + +Voti social per i badge. + +--- + +# Badge + +Attualmente i badge vengono calcolati dall'applicazione. + +Non esiste una tabella dedicata. + +--- + +# Turni + +## turni_palloni + +Gestione dei turni palloni. + +--- + +# Notifiche + +## push_subscriptions + +Dispositivi registrati per le notifiche Push. + +--- + +## promemoria_push + +Storico dei promemoria inviati. + +--- + +# Funzioni speciali + +## cacche_partita + +Sondaggio prepartita. + +Utilizzato per statistiche e badge segreti. + +--- + +# Moduli futuri + +Da implementare + +- Certificati medici +- Tesseramenti CSI +- Database allenamenti +- AI Allenamenti +- Integrazione CSI \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..4fb2502 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,46 @@ +# Roadmap + +## Versione 1.0 + +- [x] Gestione squadra +- [x] Calendario +- [x] Presenze +- [x] Scout Live +- [x] Badge +- [x] Obiettivi di squadra +- [x] Notifiche Push + +--- + +## Versione 1.1 + +- [ ] Certificati medici +- [ ] Gestione tesseramenti CSI +- [ ] Dashboard amministratore +- [ ] Download CSV dati + +--- + +## Versione 1.2 + +- [ ] Database esercizi +- [ ] AI Allenamenti +- [ ] Archivio allenamenti + +--- + +## Versione 2.0 + +- [ ] Collegamento CSI +- [ ] Classifica automatica +- [ ] Risultati campionato +- [ ] Calendario ufficiale + +--- + +## Idee future + +- [ ] Gestione quote +- [ ] Calendario Google +- [ ] Backup automatici +- [ ] Analisi statistiche avanzate \ No newline at end of file diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..011b5ee --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,29 @@ +# TODO + +## In corso + +- Documentazione tecnica del progetto. + +--- + +## Prossimo + +- Certificati medici. +- Gestione tesseramenti CSI. + +--- + +## Backlog + +- AI Allenamenti. +- Dashboard amministratore. +- Collegamento CSI. + +--- + +## Idee + +- Gestione quote. +- Widget meteo. +- Analisi Scout con AI. +- Backup automatici. \ No newline at end of file diff --git a/docs/VISION.md b/docs/VISION.md new file mode 100644 index 0000000..6aa2495 --- /dev/null +++ b/docs/VISION.md @@ -0,0 +1,38 @@ +# 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/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md new file mode 100644 index 0000000..5c5fb4e --- /dev/null +++ b/docs/modules/profilo-giocatore.md @@ -0,0 +1,281 @@ +# 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 + +--- + +# Flusso utente + +## Primo accesso + +1. Login tramite Google oppure Email. +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% + +La barra è 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 + +Contiene. + +### Dati squadra + +Solo lettura. + +- Nome +- Cognome +- Numero di maglia +- Ruolo + +Questi dati sono gestiti esclusivamente dagli amministratori. + +--- + +### 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. + +Per ogni giocatore vengono mostrati. + +- Stato del profilo +- Certificato medico +- Documento di identità +- Foto tessera + +Azioni disponibili. + +- Visualizza profilo +- Scarica certificato +- Scarica documento +- Scarica foto tessera + +--- + +# 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. + +## Pesi + +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. + +Può scaricare tutti i documenti. + +Può esportare i dati. + +--- + +# 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 \ No newline at end of file From 19e1bb0790093fdf8329e2549e72d4252b9d4144 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Fri, 7 Aug 2026 19:42:21 +0200 Subject: [PATCH 03/26] Add AI documentation and project governance --- docs/DESIGN_DECISIONS.md | 452 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 docs/DESIGN_DECISIONS.md diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md new file mode 100644 index 0000000..f7bd81c --- /dev/null +++ b/docs/DESIGN_DECISIONS.md @@ -0,0 +1,452 @@ +# 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?* + +--- + +## 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. + +--- + +## 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. + +--- + +## 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. + +--- + +## Template per nuove decisioni + +Copiare questo blocco in fondo al documento quando serve registrare una nuova scelta. + +--- + +### 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] + +--- + +## Indice rapido + +| ID | Titolo | Stato | +|---|---|---| +| DD-001 | Indipendenza da Lovable | Accettata | +| DD-002 | Sviluppo document-first | Accettata | +| DD-003 | Branch main / develop | Accettata | +| DD-004 | Ogni versione aggiunge, non riscrive | Accettata | +| DD-005 | Mobile-first | Accettata | +| DD-006 | AI solo se utile | Accettata | +| DD-007 | Badge calcolati, non in DB | Accettata | +| DD-008 | Gamification equa tra ruoli | Accettata | +| DD-009 | CSI manuale v1.1, API v2.0 | Accettata | +| DD-010 | Niente storico certificati v1 | Accettata | +| DD-011 | Auth reale prima del profilo | Accettata | +| DD-012 | Non migrare ID in v1.1 | Accettata | +| DD-013 | Portabilità dello stack | Accettata | +| DD-014 | Convergenza schema DB | In valutazione | +| DD-015 | Rosa da hardcoded a DB | In valutazione | + +--- + +*Ultimo aggiornamento: 7 agosto 2026* From 57b36b5a09373815fa747a3d139c1ee1b77990de Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Fri, 28 Aug 2026 16:33:32 +0200 Subject: [PATCH 04/26] Reference DESIGN_DECISIONS.md in AGENTS.md for AI and contributors. Co-authored-by: Cursor --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 650a686..bdcdc8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,9 @@ Prima di implementare qualsiasi modifica leggere sempre: 3. docs/ROADMAP.md 4. docs/ARCHITECTURE.md 5. docs/DATABASE.md -6. docs/TODO.md -7. il documento interessato in docs/modules/ +6. docs/DESIGN_DECISIONS.md +7. docs/TODO.md +8. il documento interessato in docs/modules/ Non implementare funzionalità non documentate. @@ -172,6 +173,7 @@ Aggiornare sempre, quando necessario: - CHANGELOG.md - TODO.md - DATABASE.md (se il database cambia) +- DESIGN_DECISIONS.md (se si prende una decisione architetturale importante) --- # Struttura della documentazione @@ -185,6 +187,7 @@ La cartella `docs/` rappresenta la documentazione ufficiale del progetto. - ROADMAP.md → evoluzione prevista - ARCHITECTURE.md → architettura tecnica - DATABASE.md → struttura del database +- DESIGN_DECISIONS.md → registro delle decisioni di progetto - CHANGELOG.md → cronologia delle modifiche - TODO.md → attività pianificate From 32782227f4b91aaad1ad76849f3fcf5c5283f184 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Fri, 28 Aug 2026 17:00:28 +0200 Subject: [PATCH 05/26] Record approved F0 player profile data schema as DD-016. Co-authored-by: Cursor --- docs/DESIGN_DECISIONS.md | 48 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index f7bd81c..be82aa4 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -356,6 +356,51 @@ 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. + +--- + ## Decisioni in valutazione --- @@ -444,9 +489,10 @@ Copiare questo blocco in fondo al documento quando serve registrare una nuova sc | DD-011 | Auth reale prima del profilo | Accettata | | DD-012 | Non migrare ID in v1.1 | Accettata | | DD-013 | Portabilità dello stack | Accettata | +| DD-016 | Schema dati Profilo Giocatore v1.1 | Accettata | | DD-014 | Convergenza schema DB | In valutazione | | DD-015 | Rosa da hardcoded a DB | In valutazione | --- -*Ultimo aggiornamento: 7 agosto 2026* +*Ultimo aggiornamento: 28 agosto 2026* From 7b4bfe8b27816618843591a69fd316c8119e61ba Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Fri, 28 Aug 2026 17:27:58 +0200 Subject: [PATCH 06/26] Add M1 migration for giocatori_squadra roster table. Co-authored-by: Cursor --- .../20260828170400_m1_giocatori_squadra.sql | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 supabase/migrations/20260828170400_m1_giocatori_squadra.sql 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'); From 7d16bdba752696482575cec8910d120085d32362 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Sun, 30 Aug 2026 12:16:20 +0200 Subject: [PATCH 07/26] Update project state and Supabase configuration --- .gitignore | 2 ++ PROJECT_STATE.md | 39 +++++++++++++++++++++++++++++---------- supabase/config.toml | 2 +- 3 files changed, 32 insertions(+), 11 deletions(-) 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/PROJECT_STATE.md b/PROJECT_STATE.md index 4a91cbb..001e077 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,21 +1,40 @@ # Project State -Ultimo aggiornamento: 07/08/2026 +Ultimo aggiornamento: 30/08/2026 ## Stato generale Fase corrente: -Progettazione dell'area Profilo Giocatore. +Backend migrato al nuovo Supabase proprietario. M1 completata. Profilo Giocatore da implementare (M2). --- ## Infrastruttura -- Sviluppo locale configurato -- GitHub configurato -- Vercel configurato -- Branch develop operativo +- 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 --- @@ -31,15 +50,15 @@ Progettazione dell'area Profilo Giocatore. --- -## Modulo in progettazione +## Modulo da implementare -Profilo Giocatore +Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`) --- ## Prossimo sviluppo -Implementazione del modulo Profilo Giocatore. +**M2** — `profili_giocatore` + Supabase Storage privato + RLS --- @@ -47,4 +66,4 @@ Implementazione del modulo Profilo Giocatore. Il progetto segue una metodologia document-first. -Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata. \ No newline at end of file +Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata. diff --git a/supabase/config.toml b/supabase/config.toml index c87d4c8..e0800f3 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1 @@ -project_id = "hetycilxgkdmccelwerq" \ No newline at end of file +project_id = "kfkcldwncxqaixetsjes" \ No newline at end of file From baaffc66dbcadd2b2e59e122571c63ec229ce74e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 30 Aug 2026 12:20:13 +0200 Subject: [PATCH 08/26] Add CLAUDE.md guide for Claude Code sessions. --- CLAUDE.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5ba70f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +CrAPP — PWA per la gestione di una squadra di pallavolo amatoriale (CRAP Volley). Codice, commenti, nomi di variabili e documentazione sono **in italiano**: mantieni questa convenzione. + +## Regole di progetto + +[AGENTS.md](AGENTS.md) contiene le regole vincolanti per gli assistenti AI. In sintesi: + +- **Document-first**: nessuna funzionalità va implementata se non è già documentata in [docs/](docs/) (in particolare `docs/modules/.md`). Leggi il documento del modulo prima di scrivere codice. +- Lavora sul branch `develop`, mai direttamente su `main` (`main` = produzione, deploy automatico Vercel). +- Dopo una modifica aggiorna, quando pertinente: `docs/ROADMAP.md`, `docs/CHANGELOG.md`, `docs/TODO.md`, `docs/DATABASE.md` (se cambia lo schema), `docs/DESIGN_DECISIONS.md` (decisioni architetturali, formato DD-XXX con indice in fondo al file), `PROJECT_STATE.md`. +- Nessuna nuova dipendenza senza reale necessità; riusa i componenti esistenti. +- Il DB si modifica solo con una nuova migration in `supabase/migrations/`; non eliminare tabelle. +- [docs/PORTABILITA.md](docs/PORTABILITA.md) / DD-001 / DD-013: l'app deve poter girare su Node.js + PostgreSQL standard. Evita servizi esclusivi Lovable/Vercel. + +## 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 . +``` + +Non esiste una suite di test automatici: la verifica è manuale via `npm run dev` + `npm run lint`. + +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 dell'utente. + +## Architettura + +**Stack**: React 19 + TanStack Start (SSR) + Vite 8 + Tailwind 4 + Radix/shadcn, Supabase come backend, Vercel per l'hosting. + +- **Routing**: file-based in [src/routes/](src/routes/); `src/routeTree.gen.ts` è generato — non modificarlo a mano. +- **Configurazione Vite**: [vite.config.ts](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** o l'app si rompe. +- **Entry point server**: [src/server.ts](src/server.ts) avvolge l'entry di TanStack Start per intercettare gli errori SSR che h3 trasforma silenziosamente in un 500 JSON, e renderizza `renderErrorPage()`. [src/start.ts](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. + +**Livello dati** — tutta la logica di dominio sta in [src/lib/](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`) con `staleTime` lungo e mutation che invalidano la propria chiave; +- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs `palloni.ts`, `mediePagelle()` vs `usePagelle()`); +- [src/lib/rosa.ts](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. + +Vincoli di efficienza cloud (vedi `mem/`): niente polling, cache lunga, aggregati precalcolati. + +**Badge e statistiche** sono calcolati a runtime dai dati, non persistiti (DD-007). La gamification deve restare equa tra ruoli (DD-008): niente metriche che favoriscano attaccanti o liberi. + +La rosa è tuttora **hardcoded** in `src/lib/crapp-data.ts` (`rosaCSI`); la migrazione verso la tabella `giocatori_squadra` (migration `20260828170400_m1_giocatori_squadra.sql`) è in corso — vedi DD-015 e DD-016. + +## UI + +Componenti condivisi in [src/components/crapp/](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. From fee3d0b55a3d15bd70a0ff8b0e4d82b4054e4ca0 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 30 Aug 2026 12:20:51 +0200 Subject: [PATCH 09/26] Read the official standings and results from the CSI portal. The Campionato page showed hardcoded demo data. It now reads the real 2025/26 season (Campionato Open Misto Eccellenza, project 767, team 3359, Girone B) from the Livescore CSI Bologna portal. The portal has no documented API: we call the same endpoints its own pages call over ajax, so parsing must degrade gracefully. A single server route fetches them, caches for 6 hours and serves the last good payload on failure; the page falls back to the previous data when nothing is available. No browser ever contacts the portal, keeping the request count independent of how many players open the app. Also fixes the header, which claimed "Girone C - CSI Milano". Co-Authored-By: Claude Opus 5 --- docs/CHANGELOG.md | 7 ++ docs/ROADMAP.md | 6 +- docs/TODO.md | 3 +- docs/modules/collegamento-csi.md | 96 ++++++++++++++++++ src/lib/csi-core.test.ts | 113 +++++++++++++++++++++ src/lib/csi-core.ts | 163 +++++++++++++++++++++++++++++++ src/lib/csi.ts | 21 ++++ src/routeTree.gen.ts | 21 ++++ src/routes/api/public/csi.ts | 56 +++++++++++ src/routes/classifica.tsx | 57 ++++++++--- 10 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 docs/modules/collegamento-csi.md create mode 100644 src/lib/csi-core.test.ts create mode 100644 src/lib/csi-core.ts create mode 100644 src/lib/csi.ts create mode 100644 src/routes/api/public/csi.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5374edd..e596ce9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,13 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum ## Versione attuale +### 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 `docs/modules/collegamento-csi.md`. + ### Infrastruttura - Migrazione completa da Lovable a sviluppo locale. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4fb2502..30846d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -31,9 +31,9 @@ ## Versione 2.0 -- [ ] Collegamento CSI -- [ ] Classifica automatica -- [ ] Risultati campionato +- [x] Collegamento CSI (stagione 2025/26) +- [x] Classifica automatica +- [x] Risultati campionato - [ ] Calendario ufficiale --- diff --git a/docs/TODO.md b/docs/TODO.md index 011b5ee..3e98a9d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -17,7 +17,8 @@ - AI Allenamenti. - Dashboard amministratore. -- Collegamento CSI. +- Collegamento CSI: aggiornare `project_id` e `team_id` per la stagione 2026/27 + (base implementata, vedi `docs/modules/collegamento-csi.md`). --- 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/src/lib/csi-core.test.ts b/src/lib/csi-core.test.ts new file mode 100644 index 0000000..46b7dec --- /dev/null +++ b/src/lib/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 "./csi-core"; + +const htmlClassifica = ` + + + + +
PosSquadra
1Altra Squadra Girone A401613321421824
+ + + + + + + + + + + +
PosSquadra
1Paolo Poggi Volley & C.401613321431726
4 + C.R.A.P. Volley 3016106333628814271329980.561.07
`; + +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("

pagina cambiata

"), [], "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
23 - 25
25 - 18
25 - 22
", + 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
18 - 25
", + 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/src/lib/csi-core.ts b/src/lib/csi-core.ts new file mode 100644 index 0000000..e4a232b --- /dev/null +++ b/src/lib/csi-core.ts @@ -0,0 +1,163 @@ +import type { RigaClassifica } from "./crapp-data"; + +/** + * Lettura dei dati ufficiali dal portale Livescore CSI Bologna. + * Non esiste un'API documentata: usiamo gli stessi endpoint che il sito chiama + * via ajax. Nessuna autenticazione, ma nessuna garanzia di stabilità: ogni + * funzione qui deve fallire in modo pulito (array vuoto), mai lanciare. + */ + +export const CSI_BASE = "https://livescore.csibologna.it"; +/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */ +export const CSI_PROJECT_ID = 767; +/** C.R.A.P. Volley sul portale CSI. */ +export const CSI_TEAM_ID = 3359; +export const CSI_GIRONE = "Girone B"; +export const CSI_NOME_SQUADRA = "C.R.A.P. Volley"; + +export const urlClassifica = (projectId = CSI_PROJECT_ID) => + `${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`; +export const urlPartite = (teamId = CSI_TEAM_ID) => + `${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`; + +export type PartitaCsi = { + id: string; + data: string; + ora: string; + avversario: string; + casa: boolean; + /** null finché la gara non è stata giocata. */ + setNostri: number | null; + setLoro: number | null; + parziali: Array<[number, number]>; + campo: string; + competizione: string; +}; + +export type DatiCsi = { + classifica: RigaClassifica[]; + partite: PartitaCsi[]; + girone: string; + aggiornato: string; +}; + +/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */ +function normalizza(nome: string): string { + return nome.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +export function isNostraSquadra(nome: string): boolean { + return normalizza(nome) === normalizza(CSI_NOME_SQUADRA); +} + +const entita: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + nbsp: " ", + deg: "°", + apos: "'", +}; + +function testo(html: string): string { + return html + .replace(/<[^>]*>/g, "") + .replace(/&(#\d+|[a-z]+);/gi, (intero, codice: string) => + codice.startsWith("#") + ? String.fromCharCode(Number(codice.slice(1))) + : (entita[codice.toLowerCase()] ?? intero), + ) + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Estrae la classifica dal frammento HTML di `project-sheets.php`. + * Il campionato ha due gironi: prendiamo la tabella che contiene la nostra + * squadra. Colonne (indice del ``): 0 Pos · 1 Squadra · 2 Punti · + * 3 Giocate · 4 Vinte · 5 Perse · 8 Set fatti · 9 Set subiti. + */ +export function parseClassifica(html: string): RigaClassifica[] { + const tabelle = html.match(//gi) ?? []; + const nostra = tabelle.find((t) => normalizza(testo(t)).includes(normalizza(CSI_NOME_SQUADRA))); + if (!nostra) return []; + + const righe: RigaClassifica[] = []; + for (const riga of nostra.match(//gi) ?? []) { + const celle = (riga.match(//gi) ?? []).map(testo); + if (celle.length < 10) continue; + const pos = Number(celle[0]); + if (!Number.isFinite(pos) || pos === 0 || !celle[1]) continue; + righe.push({ + pos, + squadra: celle[1], + punti: Number(celle[2]) || 0, + giocate: Number(celle[3]) || 0, + vinte: Number(celle[4]) || 0, + perse: Number(celle[5]) || 0, + setFatti: Number(celle[8]) || 0, + setSubiti: Number(celle[9]) || 0, + }); + } + return righe; +} + +type EventoCsi = { + id?: number | string; + start?: string; + team1?: string; + team2?: string; + result?: string; + partials?: string; + field?: string; + project?: string; +}; + +function punteggio(result: string | undefined): [number, number] | null { + const m = /(\d+)\s*-\s*(\d+)/.exec(result ?? ""); + return m ? [Number(m[1]), Number(m[2])] : null; +} + +function parziali(partials: string | undefined): Array<[number, number]> { + return [...(partials ?? "").matchAll(/(\d+)\s*-\s*(\d+)/g)].map((m) => [ + Number(m[1]), + Number(m[2]), + ]); +} + +/** Converte gli eventi di `getEventsByTeamId.php` nel formato usato dall'app. */ +export function partiteDaEventi(eventi: unknown): PartitaCsi[] { + if (!Array.isArray(eventi)) return []; + const partite: PartitaCsi[] = []; + for (const evento of eventi as EventoCsi[]) { + const team1 = testo(evento.team1 ?? ""); + const team2 = testo(evento.team2 ?? ""); + const casa = isNostraSquadra(team1); + if (!casa && !isNostraSquadra(team2)) continue; + + const [dataIso, oraIso] = (evento.start ?? "").split("T"); + if (!dataIso) continue; + + const set = punteggio(evento.result); + const tutti = parziali(evento.partials); + partite.push({ + id: String(evento.id ?? `${dataIso}-${team1}-${team2}`), + data: dataIso, + ora: (oraIso ?? "").slice(0, 5), + avversario: casa ? team2 : team1, + casa, + setNostri: set ? (casa ? set[0] : set[1]) : null, + setLoro: set ? (casa ? set[1] : set[0]) : null, + parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]), + campo: testo(evento.field ?? ""), + competizione: testo(evento.project ?? ""), + }); + } + return partite.sort((a, b) => b.data.localeCompare(a.data)); +} + +/** Solo le gare già giocate, dalla più recente. */ +export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] { + return partite.filter((p) => p.setNostri !== null && p.setLoro !== null); +} diff --git a/src/lib/csi.ts b/src/lib/csi.ts new file mode 100644 index 0000000..e9bf78c --- /dev/null +++ b/src/lib/csi.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import type { DatiCsi } from "./csi-core"; + +export const CSI_KEY = ["csi"] as const; + +/** + * Classifica e risultati ufficiali CSI. Il server tiene una cache di 6 ore: + * qui basta una lettura per sessione. + */ +export function useCsi() { + return useQuery({ + queryKey: CSI_KEY, + queryFn: async () => { + const res = await fetch("/api/public/csi"); + if (!res.ok) throw new Error("CSI non disponibile"); + return res.json(); + }, + staleTime: 6 * 60 * 60_000, + retry: 1, + }); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 85d0be3..a7a0bda 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ScoutRouteImport } from './routes/scout' import { Route as SquadraRouteImport } from './routes/squadra' import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id' import { Route as PartitaIdRouteImport } from './routes/partita.$id' +import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio' @@ -75,6 +76,11 @@ const PartitaIdRoute = PartitaIdRouteImport.update({ path: '/partita/$id', getParentRoute: () => rootRouteImport, } as any) +const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({ + id: '/api/public/csi', + path: '/api/public/csi', + getParentRoute: () => rootRouteImport, +} as any) const ApiPublicPromemoriaPalloniRoute = ApiPublicPromemoriaPalloniRouteImport.update({ id: '/api/public/promemoria-palloni', @@ -114,6 +120,7 @@ export interface FileRoutesByFullPath { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute @@ -131,6 +138,7 @@ export interface FileRoutesByTo { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute @@ -149,6 +157,7 @@ export interface FileRoutesById { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute @@ -168,6 +177,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' | '/api/public/push-messaggio' @@ -185,6 +195,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' | '/api/public/push-messaggio' @@ -202,6 +213,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' | '/api/public/push-messaggio' @@ -220,6 +232,7 @@ export interface RootRouteChildren { SquadraRoute: typeof SquadraRoute AllenamentoIdRoute: typeof AllenamentoIdRoute PartitaIdRoute: typeof PartitaIdRoute + ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute @@ -299,6 +312,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PartitaIdRouteImport parentRoute: typeof rootRouteImport } + '/api/public/csi': { + id: '/api/public/csi' + path: '/api/public/csi' + fullPath: '/api/public/csi' + preLoaderRoute: typeof ApiPublicCsiRouteImport + parentRoute: typeof rootRouteImport + } '/api/public/promemoria-palloni': { id: '/api/public/promemoria-palloni' path: '/api/public/promemoria-palloni' @@ -348,6 +368,7 @@ const rootRouteChildren: RootRouteChildren = { SquadraRoute: SquadraRoute, AllenamentoIdRoute: AllenamentoIdRoute, PartitaIdRoute: PartitaIdRoute, + ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute, 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/classifica.tsx b/src/routes/classifica.tsx index ea29d39..30ba547 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -4,6 +4,8 @@ import { cn } from "@/lib/utils"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { storicoMatch } from "@/lib/crapp-data"; import { classificaConScout, useScoutMatches } from "@/lib/scout-store"; +import { useCsi } from "@/lib/csi"; +import { isNostraSquadra, partiteGiocate } from "@/lib/csi-core"; import { ScoutEntry } from "@/components/crapp/ScoutEntry"; export const Route = createFileRoute("/classifica")({ @@ -21,27 +23,50 @@ export const Route = createFileRoute("/classifica")({ component: Classifica, }); +function formatAggiornamento(iso: string) { + const d = new Date(iso); + const oggi = new Date().toDateString() === d.toDateString(); + const ora = d.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" }); + return oggi ? `oggi alle ${ora}` : `il ${d.toLocaleDateString("it-IT")} alle ${ora}`; +} + function Classifica() { const scoutMatches = useScoutMatches(); - const classifica = classificaConScout(scoutMatches); - const risultati = [ - ...scoutMatches.map((m) => ({ - 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 +} From af563603f992fca4e5423b70753af7a779910ec4 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 30 Aug 2026 13:07:41 +0200 Subject: [PATCH 10/26] Add a test suite for the domain logic and the server routes. The project had no automated verification at all, so the "Test" step in the AGENTS.md workflow rested entirely on clicking through the app. The suite runs on bun with node:assert and adds no dependency: every file is a script that exits non-zero when a check fails, and test/run.ts runs each one in its own process. Unit tests cover the rules that decide what players see (badges, streaks, ball duty rotation, ratings, MVP ties, scouting totals, goals, notifications, CSI parsing); integration and end-to-end tests drive the real dev server. Nothing writes to the database, so both can be pointed at a live environment. Co-Authored-By: Claude Opus 5 --- package.json | 6 +- test/e2e/percorsi.test.ts | 114 ++++++++++++++++ test/helpers/prova.ts | 32 +++++ test/helpers/server.ts | 111 ++++++++++++++++ test/integration/api.test.ts | 153 ++++++++++++++++++++++ test/run.ts | 45 +++++++ test/unit/badge-social.test.ts | 62 +++++++++ test/unit/badges.test.ts | 166 ++++++++++++++++++++++++ test/unit/cacche.test.ts | 55 ++++++++ test/unit/crapp-data.test.ts | 85 ++++++++++++ {src/lib => test/unit}/csi-core.test.ts | 2 +- test/unit/eventi.test.ts | 64 +++++++++ test/unit/infortuni.test.ts | 33 +++++ test/unit/mvp-voti.test.ts | 53 ++++++++ test/unit/notifiche-smart.test.ts | 106 +++++++++++++++ test/unit/obiettivi.test.ts | 147 +++++++++++++++++++++ test/unit/pagelle.test.ts | 64 +++++++++ test/unit/palloni-core.test.ts | 98 ++++++++++++++ test/unit/scout-export.test.ts | 81 ++++++++++++ test/unit/scout-store.test.ts | 120 +++++++++++++++++ test/unit/serie.test.ts | 60 +++++++++ tsconfig.json | 2 +- 22 files changed, 1656 insertions(+), 3 deletions(-) create mode 100644 test/e2e/percorsi.test.ts create mode 100644 test/helpers/prova.ts create mode 100644 test/helpers/server.ts create mode 100644 test/integration/api.test.ts create mode 100644 test/run.ts create mode 100644 test/unit/badge-social.test.ts create mode 100644 test/unit/badges.test.ts create mode 100644 test/unit/cacche.test.ts create mode 100644 test/unit/crapp-data.test.ts rename {src/lib => test/unit}/csi-core.test.ts (99%) create mode 100644 test/unit/eventi.test.ts create mode 100644 test/unit/infortuni.test.ts create mode 100644 test/unit/mvp-voti.test.ts create mode 100644 test/unit/notifiche-smart.test.ts create mode 100644 test/unit/obiettivi.test.ts create mode 100644 test/unit/pagelle.test.ts create mode 100644 test/unit/palloni-core.test.ts create mode 100644 test/unit/scout-export.test.ts create mode 100644 test/unit/scout-store.test.ts create mode 100644 test/unit/serie.test.ts 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/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts new file mode 100644 index 0000000..aea1fbf --- /dev/null +++ b/test/e2e/percorsi.test.ts @@ -0,0 +1,114 @@ +/** + * 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/], + ]; + 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/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..8092981 --- /dev/null +++ b/test/unit/crapp-data.test.ts @@ -0,0 +1,85 @@ +/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */ +import assert from "node:assert/strict"; +import { + adminNomi, + classifica, + formatData, + giocatori, + isAdmin, + 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`); +} + +// --- amministratori ---------------------------------------------------------- +for (const nome of adminNomi) { + const admin = giocatori.find((g) => g.nome === nome); + assert.ok(admin, `l'amministratore ${nome} è in rosa`); + assert.equal(isAdmin(admin.id), true); +} +const nonAdmin = giocatori.find((g) => !adminNomi.includes(g.nome))!; +assert.equal(isAdmin(nonAdmin.id), false); +assert.equal(isAdmin("g999"), false, "un id inesistente non è amministratore"); +assert.equal(isAdmin(""), false); + +// --- 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/src/lib/csi-core.test.ts b/test/unit/csi-core.test.ts similarity index 99% rename from src/lib/csi-core.test.ts rename to test/unit/csi-core.test.ts index 46b7dec..a907b79 100644 --- a/src/lib/csi-core.test.ts +++ b/test/unit/csi-core.test.ts @@ -10,7 +10,7 @@ import { partiteGiocate, urlClassifica, urlPartite, -} from "./csi-core"; +} from "@/lib/csi-core"; const htmlClassifica = ` <table class="ranking"> 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/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-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", From 9ca124a868d20b9b7043c3808c63e3f66c80854d Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 13:07:59 +0200 Subject: [PATCH 11/26] Expire a scout session whose timestamp cannot be read. sessioneScaduta compared Date.now() against NaN when aggiornato_il was not a valid date, and every comparison with NaN is false: the session was reported as still active, so the Scout Live table stayed locked to a player who could no longer release it. An unreadable timestamp now frees the session, which is the safe direction: at worst someone takes over a scouting session that was already unattended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/lib/scout-live.ts | 16 ++++++++-- test/unit/scout-live.test.ts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 test/unit/scout-live.test.ts diff --git a/src/lib/scout-live.ts b/src/lib/scout-live.ts index 0892123..c1712c3 100644 --- a/src/lib/scout-live.ts +++ b/src/lib/scout-live.ts @@ -24,7 +24,10 @@ export type SessioneScout = { export function sessioneScaduta(s: SessioneScout | null): boolean { if (!s) return true; - return Date.now() - new Date(s.aggiornato_il).getTime() > SCADENZA_MINUTI * 60_000; + const aggiornato = new Date(s.aggiornato_il).getTime(); + // Timestamp illeggibile: meglio liberare la sessione che lasciarla bloccata per sempre. + if (Number.isNaN(aggiornato)) return true; + return Date.now() - aggiornato > SCADENZA_MINUTI * 60_000; } const storageKey = (eventoId: string) => `crap-scout-session-${eventoId}`; @@ -82,7 +85,10 @@ export function useSessioneScout(eventoId: string | null) { } catch { const onStorage = (e: StorageEvent) => { if (e.key === storageKey(eventoId)) { - queryClient.setQueryData(SESSIONE_KEY(eventoId), e.newValue ? (JSON.parse(e.newValue) as SessioneScout) : null); + queryClient.setQueryData( + SESSIONE_KEY(eventoId), + e.newValue ? (JSON.parse(e.newValue) as SessioneScout) : null, + ); } }; window.addEventListener("storage", onStorage); @@ -140,7 +146,11 @@ export function useChiudiSessioneScout() { } /** Mantiene viva la sessione mentre lo scout è aperto. */ -export function useHeartbeatScout(eventoId: string | null, giocatoreId: string | null, attivo: boolean) { +export function useHeartbeatScout( + eventoId: string | null, + giocatoreId: string | null, + attivo: boolean, +) { useEffect(() => { if (!attivo || !eventoId || !giocatoreId || typeof window === "undefined") return; const id = window.setInterval(() => { 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"); From c06b33e83bdd8c5b5f2e6b1d2952aed630f3b9b6 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 13:07:59 +0200 Subject: [PATCH 12/26] Document how to run the tests. test/README.md covers the commands, what each folder verifies, whether it needs the network, and the one real limit: the app renders after hydration, so the end-to-end tests check HTTP responses and the data behind the pages, not the rendered interface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/CHANGELOG.md | 7 +++++++ test/README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 test/README.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e596ce9..05cf4ba 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,13 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum ## Versione attuale +### 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 diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..9bba501 --- /dev/null +++ b/test/README.md @@ -0,0 +1,44 @@ +# 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 | 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. +- `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. From e1e8dd541538fdf94b64e8559070c09dae5ac025 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 16:26:14 +0200 Subject: [PATCH 13/26] Reorganize documentation and unify the AI assistant rules Documentation: - Add docs/README.md, the documentation index that AGENTS.md pointed to as the first file to read but which did not exist. - One home per piece of information: the feature list stays in ROADMAP.md, CHANGELOG.md records only when something shipped, TODO.md only ongoing work. Reconcile the entries that had drifted (CSI was both done and pending; pagelle, badge social and serie were missing from the roadmap). - Rewrite DATABASE.md as tables: add giocatori_squadra (already created by a migration) and profili_giocatore (planned in DD-016), fix the wrong heading levels, drop the duplicated roadmap. - DESIGN_DECISIONS.md: move the index to the top and sort it, extract the template into _template-dd.md. - ARCHITECTURE.md becomes the technical reference; CLAUDE.md no longer duplicates it. - Add docs/EFFICIENZA_CLOUD.md with the rules previously kept in mem/, separating what the code enforces from the goals not yet implemented. - Fix statements the code contradicted: mutations use setQueryData rather than invalidateQueries, and scout_sessioni and giocatori_squadra are not read by the code yet. - Track the v1.0 modules with no spec in docs/modules/ from TODO.md. AI assistants: - AGENTS.md is the single source of the rules, now including the technical constraints only Claude Code knew about (generated files, Vite plugins, data access) and an end-of-work checklist that applies to every assistant. - CLAUDE.md and .cursor/rules/crapp.mdc point to AGENTS.md instead of restating it. - Remove the five .cursor/*.md files, which Cursor never loaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .cursor/architecture-summary.md | 37 ---- .cursor/coding-style.md | 12 -- .cursor/crapp-context.md | 22 -- .cursor/development-workflow.md | 11 - .cursor/project-rules.md | 29 --- .cursor/rules/crapp.mdc | 16 ++ AGENTS.md | 330 ++++++++++-------------------- CLAUDE.md | 57 ++---- docs/ARCHITECTURE.md | 134 +++++++----- docs/CHANGELOG.md | 26 +-- docs/DATABASE.md | 185 ++++------------- docs/DESIGN_DECISIONS.md | 89 ++++---- docs/EFFICIENZA_CLOUD.md | 36 ++++ docs/README.md | 44 ++++ docs/ROADMAP.md | 23 ++- docs/TODO.md | 41 ++-- docs/VISION.md | 6 - docs/_template-dd.md | 20 ++ docs/modules/profilo-giocatore.md | 143 ++++--------- mem/features/cloud-efficienza.md | 14 -- mem/features/portabilita.md | 16 -- mem/index.md | 6 +- 22 files changed, 485 insertions(+), 812 deletions(-) delete mode 100644 .cursor/architecture-summary.md delete mode 100644 .cursor/coding-style.md delete mode 100644 .cursor/crapp-context.md delete mode 100644 .cursor/development-workflow.md delete mode 100644 .cursor/project-rules.md create mode 100644 .cursor/rules/crapp.mdc create mode 100644 docs/EFFICIENZA_CLOUD.md create mode 100644 docs/README.md create mode 100644 docs/_template-dd.md delete mode 100644 mem/features/cloud-efficienza.md delete mode 100644 mem/features/portabilita.md diff --git a/.cursor/architecture-summary.md b/.cursor/architecture-summary.md deleted file mode 100644 index e37235c..0000000 --- a/.cursor/architecture-summary.md +++ /dev/null @@ -1,37 +0,0 @@ -# Architecture Summary - -Frontend - -- React -- TanStack Start -- Tailwind -- TypeScript - -Backend - -- Supabase - -Hosting - -- Vercel - -Repository - -- GitHub - -Branch - -- main -- develop - -Documentazione - -- docs/ - -Database - -- Supabase - -Storage - -- Supabase Storage \ No newline at end of file diff --git a/.cursor/coding-style.md b/.cursor/coding-style.md deleted file mode 100644 index a695ac0..0000000 --- a/.cursor/coding-style.md +++ /dev/null @@ -1,12 +0,0 @@ -# Coding Style - -Preferenze del progetto. - -- Utilizzare TypeScript. -- Preferire funzioni piccole. -- Evitare duplicazione di codice. -- Utilizzare componenti React riutilizzabili. -- Commentare solamente il codice realmente complesso. -- Preferire nomi descrittivi. -- Non introdurre librerie senza reale necessità. -- Mantenere la struttura esistente del progetto. \ No newline at end of file diff --git a/.cursor/crapp-context.md b/.cursor/crapp-context.md deleted file mode 100644 index d03bd8a..0000000 --- a/.cursor/crapp-context.md +++ /dev/null @@ -1,22 +0,0 @@ -# CrAPP Context - -CrAPP è una Progressive Web App dedicata alla gestione di una squadra di pallavolo amatoriale. - -L'obiettivo principale NON è solamente registrare dati. - -L'obiettivo è ridurre il lavoro amministrativo degli amministratori e aumentare il coinvolgimento dei giocatori attraverso gamification, statistiche e strumenti intelligenti. - -Quando implementi nuove funzionalità: - -- privilegia semplicità -- mantieni la coerenza dell'interfaccia -- evita duplicazioni -- leggi sempre la documentazione presente in `docs/` - -Prima di scrivere codice consulta: - -- README -- ROADMAP -- DATABASE -- ARCHITECTURE -- il modulo interessato in `docs/modules` \ No newline at end of file diff --git a/.cursor/development-workflow.md b/.cursor/development-workflow.md deleted file mode 100644 index fe70802..0000000 --- a/.cursor/development-workflow.md +++ /dev/null @@ -1,11 +0,0 @@ -# Development Workflow - -Ogni nuova funzionalità segue questo flusso. - -1. Discussione funzionale. -2. Documento in `docs/modules`. -3. Progettazione database. -4. Implementazione su branch `develop`. -5. Test. -6. Merge su `main`. -7. Deploy automatico tramite Vercel. \ No newline at end of file diff --git a/.cursor/project-rules.md b/.cursor/project-rules.md deleted file mode 100644 index cbd9c92..0000000 --- a/.cursor/project-rules.md +++ /dev/null @@ -1,29 +0,0 @@ -# Project Rules - -Queste regole devono essere rispettate per qualsiasi modifica al progetto. - -## Regole generali - -- Non modificare il branch `main` direttamente. -- Tutte le nuove funzionalità vengono sviluppate su `develop`. -- Prima di implementare una funzionalità leggere sempre la documentazione presente in `docs/`. -- Non creare codice duplicato. -- Riutilizzare sempre componenti già esistenti quando possibile. -- Mantenere uno stile coerente con il progetto. - -## Database - -- Non modificare il database senza creare una nuova migration Supabase. -- Non eliminare tabelle esistenti senza esplicita richiesta. -- Preferire nuove tabelle rispetto all'aggiunta di molte colonne quando il modulo è indipendente. - -## Componenti - -- Preferire componenti piccoli e riutilizzabili. -- Evitare componenti con responsabilità multiple. - -## Documentazione - -Ogni nuova funzionalità deve essere documentata prima dell'implementazione. - -La documentazione tecnica si trova nella cartella `docs/`. \ No newline at end of file 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/AGENTS.md b/AGENTS.md index bdcdc8a..9a774ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,231 +1,127 @@ -# AGENTS.md - -# 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 -2. docs/VISION.md -3. docs/ROADMAP.md -4. docs/ARCHITECTURE.md -5. docs/DATABASE.md -6. docs/DESIGN_DECISIONS.md -7. docs/TODO.md -8. il documento interessato in docs/modules/ - -Non implementare funzionalità non documentate. - ---- - -# Workflow di sviluppo - -Ogni nuova funzionalità segue sempre questo processo. - -Idea - -↓ - -Progettazione - -↓ - -Documentazione - -↓ - -Database - -↓ - -Implementazione - -↓ - -Test - -↓ - -Merge su main - -↓ - -Deploy automatico - ---- - -# Git - -Il repository utilizza due branch principali. - -## main - -Versione stabile. - -Qualsiasi modifica deve mantenere l'app perfettamente funzionante. - -## develop - -Branch utilizzato per lo sviluppo delle nuove funzionalità. - -Tutte le nuove implementazioni devono essere realizzate qui. - ---- - -# 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. - -Fare sempre riferimento a: - -docs/DATABASE.md - ---- - -# Componenti - -Preferire: - -- componenti piccoli; -- componenti riutilizzabili; -- responsabilità singola; -- codice semplice da mantenere. - -Evitare duplicazioni. - ---- - -# 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 -- CHANGELOG.md -- TODO.md -- DATABASE.md (se il database cambia) -- DESIGN_DECISIONS.md (se si prende una decisione architetturale importante) ---- - -# Struttura della documentazione - -La cartella `docs/` rappresenta la documentazione ufficiale del progetto. - -## Documenti principali - -- README.md → panoramica del progetto -- VISION.md → obiettivi e filosofia -- ROADMAP.md → evoluzione prevista -- ARCHITECTURE.md → architettura tecnica -- DATABASE.md → struttura del database -- DESIGN_DECISIONS.md → registro delle decisioni di progetto -- CHANGELOG.md → cronologia delle modifiche -- TODO.md → attività pianificate - -## 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. ---- - -# Regole - -L'AI non deve: +# AGENTS.md — CrAPP + +Regole che qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT o altri) deve seguire +quando lavora su questo progetto. **È l'unica fonte delle regole**: `CLAUDE.md` e +`.cursor/rules/` rimandano qui, non ripetono nulla. + +> **Qualsiasi cosa venga aggiunta o modificata — una regola, una funzionalità, una decisione, +> una tabella — va registrata nei file di riferimento del progetto prima di considerare il +> lavoro finito**, indipendentemente dall'assistente con cui è stata fatta. Vedi +> [Fine lavoro](#fine-lavoro-cosa-aggiornare-sempre): non è un passaggio opzionale. + +CrAPP è una Progressive Web App per la gestione di una squadra di pallavolo amatoriale (CRAP +Volley). Obiettivi e principi in [docs/VISION.md](docs/VISION.md), architettura in +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +**Codice, commenti, nomi di variabili e documentazione sono in italiano**: mantieni questa +convenzione. + +## Prima di modificare il codice + +Leggere sempre, nell'ordine: + +1. [docs/README.md](docs/README.md) — indice della documentazione +2. [docs/VISION.md](docs/VISION.md) +3. [docs/ROADMAP.md](docs/ROADMAP.md) +4. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +5. [docs/DATABASE.md](docs/DATABASE.md) +6. [docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) +7. [docs/TODO.md](docs/TODO.md) +8. il documento del modulo interessato in [docs/modules/](docs/modules/) + +**Non implementare funzionalità non documentate** (DD-002). + +## Workflow + +``` +idea → progettazione → documento in docs/modules/ → database → implementazione su develop +→ test → merge su main → deploy automatico Vercel +``` + +- `main` = produzione, sempre funzionante: **non si modifica direttamente**. Qualsiasi + modifica deve mantenere l'app perfettamente funzionante. +- `develop` = sviluppo e preview; tutte le nuove implementazioni nascono qui. + +## Vincoli tecnici da non violare + +- `src/routeTree.gen.ts` e `src/integrations/supabase/{client.ts,types.ts}` sono **generati**: + non modificarli a mano. +- **Non ri-aggiungere** i plugin Vite (devtools, tanstackStart, viteReact, tailwind, + tsconfig-paths, nitro) già inclusi da `@lovable.dev/vite-tanstack-config` in + `vite.config.ts`: l'app si rompe. +- Nessun accesso al database dai componenti: solo tramite i moduli in `src/lib/`, così il + backend resta sostituibile in un solo punto (DD-013). +- Efficienza cloud: niente polling, cache lunga, e dopo una mutazione `setQueryData` invece di + `invalidateQueries`. Regole complete in [docs/EFFICIENZA_CLOUD.md](docs/EFFICIENZA_CLOUD.md). +- Badge e statistiche si calcolano a runtime, non si persistono (DD-007); la gamification + resta equa tra ruoli (DD-008): niente metriche che favoriscano attaccanti o liberi. +- L'app deve poter girare su Node.js + PostgreSQL standard: niente servizi esclusivi + Lovable/Vercel (DD-001, DD-013, [docs/PORTABILITA.md](docs/PORTABILITA.md)). + +## Database + +- Non eliminare tabelle esistenti. +- Non modificare lo schema senza creare una migration in `supabase/migrations/`. +- Preferire una nuova tabella all'aggiunta di molte colonne, quando il modulo è indipendente. +- Preferire strutture scalabili; evitare duplicazione dei dati. +- Riferimento: [docs/DATABASE.md](docs/DATABASE.md), da aggiornare nella stessa modifica che + cambia lo schema. + +## Codice e componenti + +- TypeScript, funzioni piccole, nomi descrittivi. +- Componenti piccoli, riutilizzabili, a responsabilità singola. +- Nessuna duplicazione: riusare sempre i componenti e i moduli esistenti. +- Commentare solo il codice realmente complesso. +- Nessuna nuova dipendenza senza reale necessità; mantenere la struttura esistente. +- Le dipendenze si installano con **bun**; `bunfig.toml` impone `minimumReleaseAge = 24h` come + guardia supply-chain: aggiungere un pacchetto a `minimumReleaseAgeExcludes` richiede + conferma esplicita dell'utente. + +## Interfaccia + +Stile coerente con l'esistente: semplice, moderna, pulita, veloce, ottimizzata per +smartphone, poche schermate e pochi click (DD-005). Riusare i componenti in +`src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`, `StatTile`) e le primitive +shadcn in `src/components/ui/`. + +## Fine lavoro: cosa aggiornare sempre + +Il lavoro **non è finito** finché non è registrato dove va. Vale per tutti gli assistenti allo +stesso modo: chi fa la modifica aggiorna i file, chiunque la stia facendo e da qualunque +strumento. Un cambiamento che vive solo nel codice o solo nella chat è un cambiamento perso. + +| Cosa hai aggiunto o cambiato | Dove va registrato | +|---|---| +| Una **regola** per gli assistenti (convenzione, divieto, vincolo di lavoro) | **Questo file, e solo questo.** Mai in `CLAUDE.md` o `.cursor/rules/`: rimandano qui, e una regola scritta lì la vedrebbe un assistente solo | +| Una **funzionalità** | `docs/modules/<modulo>.md` (**prima** di scrivere il codice), poi `docs/ROADMAP.md` e `docs/CHANGELOG.md` | +| Una **decisione** architetturale o di prodotto | `docs/DESIGN_DECISIONS.md`, formato `DD-XXX` (copiare `docs/_template-dd.md`) e aggiungerla all'indice in cima | +| Una **modifica allo schema** del database | una migration in `supabase/migrations/` **e** `docs/DATABASE.md`, nella stessa modifica | +| Lavoro iniziato, sospeso o concluso | `docs/TODO.md` e `PROJECT_STATE.md` | +| Un **comando** o uno script nuovo | `docs/ARCHITECTURE.md` (sezione Comandi) e `CLAUDE.md` | + +Quale informazione vive in quale file — e perché non va duplicata altrove — è spiegato in +[docs/README.md](docs/README.md). + +## Cosa l'AI non deve fare - introdurre librerie senza necessità; - modificare il database senza motivazione; - eliminare funzionalità esistenti; - modificare il comportamento dell'app senza richiesta esplicita. -L'AI deve: +## Cosa l'AI deve fare - spiegare le modifiche importanti; - mantenere compatibilità con il codice esistente; - privilegiare la semplicità; - riutilizzare i componenti esistenti. ---- +## Filosofia -# Filosofia del progetto +Prima di scrivere codice, chiedersi sempre: -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? - -Se almeno una risposta è negativa, rivalutare la soluzione proposta. \ No newline at end of file +- questa modifica rende CrAPP più semplice? +- riduce il lavoro degli amministratori? +- migliora l'esperienza dei giocatori? +- è coerente con la documentazione? diff --git a/CLAUDE.md b/CLAUDE.md index 5ba70f4..4157f10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,54 +1,21 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +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). -CrAPP — PWA per la gestione di una squadra di pallavolo amatoriale (CRAP Volley). Codice, commenti, nomi di variabili e documentazione sono **in italiano**: mantieni questa convenzione. - -## Regole di progetto - -[AGENTS.md](AGENTS.md) contiene le regole vincolanti per gli assistenti AI. In sintesi: - -- **Document-first**: nessuna funzionalità va implementata se non è già documentata in [docs/](docs/) (in particolare `docs/modules/<modulo>.md`). Leggi il documento del modulo prima di scrivere codice. -- Lavora sul branch `develop`, mai direttamente su `main` (`main` = produzione, deploy automatico Vercel). -- Dopo una modifica aggiorna, quando pertinente: `docs/ROADMAP.md`, `docs/CHANGELOG.md`, `docs/TODO.md`, `docs/DATABASE.md` (se cambia lo schema), `docs/DESIGN_DECISIONS.md` (decisioni architetturali, formato DD-XXX con indice in fondo al file), `PROJECT_STATE.md`. -- Nessuna nuova dipendenza senza reale necessità; riusa i componenti esistenti. -- Il DB si modifica solo con una nuova migration in `supabase/migrations/`; non eliminare tabelle. -- [docs/PORTABILITA.md](docs/PORTABILITA.md) / DD-001 / DD-013: l'app deve poter girare su Node.js + PostgreSQL standard. Evita servizi esclusivi Lovable/Vercel. +**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 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 ``` -Non esiste una suite di test automatici: la verifica è manuale via `npm run dev` + `npm run lint`. - -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 dell'utente. - -## Architettura - -**Stack**: React 19 + TanStack Start (SSR) + Vite 8 + Tailwind 4 + Radix/shadcn, Supabase come backend, Vercel per l'hosting. - -- **Routing**: file-based in [src/routes/](src/routes/); `src/routeTree.gen.ts` è generato — non modificarlo a mano. -- **Configurazione Vite**: [vite.config.ts](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** o l'app si rompe. -- **Entry point server**: [src/server.ts](src/server.ts) avvolge l'entry di TanStack Start per intercettare gli errori SSR che h3 trasforma silenziosamente in un 500 JSON, e renderizza `renderErrorPage()`. [src/start.ts](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. - -**Livello dati** — tutta la logica di dominio sta in [src/lib/](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`) con `staleTime` lungo e mutation che invalidano la propria chiave; -- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs `palloni.ts`, `mediePagelle()` vs `usePagelle()`); -- [src/lib/rosa.ts](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. - -Vincoli di efficienza cloud (vedi `mem/`): niente polling, cache lunga, aggregati precalcolati. - -**Badge e statistiche** sono calcolati a runtime dai dati, non persistiti (DD-007). La gamification deve restare equa tra ruoli (DD-008): niente metriche che favoriscano attaccanti o liberi. - -La rosa è tuttora **hardcoded** in `src/lib/crapp-data.ts` (`rosaCSI`); la migrazione verso la tabella `giocatori_squadra` (migration `20260828170400_m1_giocatori_squadra.sql`) è in corso — vedi DD-015 e DD-016. - -## UI - -Componenti condivisi in [src/components/crapp/](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. +Verifica minima prima di consegnare: `npm run lint` + `npm run test`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5335322..f8bcc39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,71 +1,105 @@ # Architettura del progetto -## Frontend +Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di +riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama. -- React 19 -- TypeScript -- TanStack Start -- Vite -- Tailwind CSS -- Radix UI +## 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 | -## Backend - -- Supabase - ---- - -## Hosting - -- Vercel - ---- - -## Versionamento - -- Git -- GitHub - ---- - -## Branch - -- main → Produzione -- develop → Sviluppo - ---- +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 +``` -- components/ -- routes/ -- lib/ -- integrations/ -- hooks/ -- assets/ +## Punti fermi -supabase/ +- **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. -docs/ +## 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: -## Flusso di sviluppo +- 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. -develop +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). -Test +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. -Merge su main +## 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. -Deploy automatico su Vercel \ No newline at end of file +## 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 +``` + +## 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 index 05cf4ba..196999b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -Tutte le modifiche significative del progetto vengono registrate in questo documento. +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 +## Versione attuale — agosto 2026 ### Test @@ -18,7 +18,7 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum - 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 `docs/modules/collegamento-csi.md`. +- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md). ### Infrastruttura @@ -28,17 +28,7 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum - Deploy automatico tramite Vercel. - Branch main e develop. ---- +## Versione 1.0 — luglio 2026 -## Funzionalità implementate - -- Gestione squadra -- Calendario -- Presenze -- Scout Live -- Badge -- Obiettivi di squadra -- Pagelle -- Badge social -- Serie di presenze -- Notifiche intelligenti \ No newline at end of file +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 index 738d2e6..0cd85bb 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -1,159 +1,60 @@ # Database CrAPP -## Obiettivo +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. -Questo documento descrive la struttura del database Supabase e il ruolo di ogni tabella. +## 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`. | **Prevista** per la v1.1 (DD-016), non ancora creata. | +| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | | -# Utenti +`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle. -## giocatori +## Eventi e presenze -Contiene l'anagrafica dei giocatori. +| 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). | -Utilizzato da: +## Scout -- Squadra -- Profili -- Presenze -- Scout -- Badge -- Pagelle +| 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 -## user_roles +| 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. | | -Definisce i ruoli applicativi. +## Turni e notifiche -Esempi: +| Tabella | Scopo | Note | +|---|---|---| +| `turni_palloni` | Gestione dei turni palloni. | | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Storico dei promemoria inviati. | | -- amministratore -- giocatore +## Funzioni speciali ---- +| Tabella | Scopo | Note | +|---|---|---| +| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. | -# Eventi +## Badge -## eventi - -Calendario generale. - -Comprende: - -- allenamenti -- partite -- eventi della squadra - ---- - -## eventi_app - -Eventi gestionali utilizzati dall'app. - ---- - -# Presenze - -## presenze - -Gestisce le presenze agli eventi. - ---- - -## risposte_presenze - -Memorizza le risposte dei giocatori. - ---- - -# Scout - -## scout_sessioni - -Sessioni di Scout Live. - -Una sessione corrisponde ad una partita. - ---- - -## scout_live - -Eventi registrati durante lo Scout Live. - -Serve esclusivamente per statistiche di squadra. - ---- - -# Votazioni - -## mvp_voti - -Voti MVP assegnati a fine partita. - ---- - -## pagelle_voti - -Voti anonimi assegnati ai giocatori. - -Utilizzati per il voto medio. - ---- - -## badge_social_voti - -Voti social per i badge. - ---- - -# Badge - -Attualmente i badge vengono calcolati dall'applicazione. - -Non esiste una tabella dedicata. - ---- - -# Turni - -## turni_palloni - -Gestione dei turni palloni. - ---- - -# Notifiche - -## push_subscriptions - -Dispositivi registrati per le notifiche Push. - ---- - -## promemoria_push - -Storico dei promemoria inviati. - ---- - -# Funzioni speciali - -## cacche_partita - -Sondaggio prepartita. - -Utilizzato per statistiche e badge segreti. - ---- - -# Moduli futuri - -Da implementare - -- Certificati medici -- Tesseramenti CSI -- Database allenamenti -- AI Allenamenti -- Integrazione CSI \ No newline at end of file +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 index be82aa4..384643d 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -12,6 +12,36 @@ Serve a rispondere a domande del tipo: --- +## 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 | + +**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: @@ -39,6 +69,11 @@ Ogni decisione segue lo stesso schema: - 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 @@ -442,57 +477,3 @@ Il profilo v1.1 può agganciarsi agli ID attuali; la migrazione rosa può essere In parallelo o subito dopo il rollout auth. --- - -## Template per nuove decisioni - -Copiare questo blocco in fondo al documento quando serve registrare una nuova scelta. - ---- - -### 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] - ---- - -## Indice rapido - -| ID | Titolo | Stato | -|---|---|---| -| DD-001 | Indipendenza da Lovable | Accettata | -| DD-002 | Sviluppo document-first | Accettata | -| DD-003 | Branch main / develop | Accettata | -| DD-004 | Ogni versione aggiunge, non riscrive | Accettata | -| DD-005 | Mobile-first | Accettata | -| DD-006 | AI solo se utile | Accettata | -| DD-007 | Badge calcolati, non in DB | Accettata | -| DD-008 | Gamification equa tra ruoli | Accettata | -| DD-009 | CSI manuale v1.1, API v2.0 | Accettata | -| DD-010 | Niente storico certificati v1 | Accettata | -| DD-011 | Auth reale prima del profilo | Accettata | -| DD-012 | Non migrare ID in v1.1 | Accettata | -| DD-013 | Portabilità dello stack | Accettata | -| DD-016 | Schema dati Profilo Giocatore v1.1 | Accettata | -| DD-014 | Convergenza schema DB | In valutazione | -| DD-015 | Rosa da hardcoded a DB | In valutazione | - ---- - -*Ultimo aggiornamento: 28 agosto 2026* 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 index 30846d9..8559122 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,16 +1,21 @@ # Roadmap -## Versione 1.0 +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 - ---- +- [x] Notifiche Push (promemoria intelligenti) ## Versione 1.1 @@ -19,16 +24,12 @@ - [ ] Dashboard amministratore - [ ] Download CSV dati ---- - ## Versione 1.2 - [ ] Database esercizi - [ ] AI Allenamenti - [ ] Archivio allenamenti ---- - ## Versione 2.0 - [x] Collegamento CSI (stagione 2025/26) @@ -36,11 +37,11 @@ - [x] Risultati campionato - [ ] Calendario ufficiale ---- - ## Idee future - [ ] Gestione quote - [ ] Calendario Google - [ ] Backup automatici -- [ ] Analisi statistiche avanzate \ No newline at end of file +- [ ] Analisi statistiche avanzate +- [ ] Widget meteo +- [ ] Analisi Scout con AI diff --git a/docs/TODO.md b/docs/TODO.md index 3e98a9d..e1967ba 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,30 +1,35 @@ # 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. ---- - ## Prossimo -- Certificati medici. -- Gestione tesseramenti CSI. +- Certificati medici (roadmap v1.1). +- Gestione tesseramenti CSI (roadmap v1.1). ---- +## 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. -- Dashboard amministratore. -- Collegamento CSI: aggiornare `project_id` e `team_id` per la stagione 2026/27 - (base implementata, vedi `docs/modules/collegamento-csi.md`). - ---- - -## Idee - -- Gestione quote. -- Widget meteo. -- Analisi Scout con AI. -- Backup automatici. \ No newline at end of file +- AI Allenamenti (roadmap v1.2). +- Dashboard amministratore (roadmap v1.1). +- Backup automatici (roadmap, idee future). diff --git a/docs/VISION.md b/docs/VISION.md index 6aa2495..bfe6a71 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -6,8 +6,6 @@ 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: @@ -18,8 +16,6 @@ Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi: - Automatizzare le attività ripetitive. - Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio. ---- - ## Filosofia CrAPP deve essere: @@ -31,8 +27,6 @@ CrAPP deve essere: - 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/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 5c5fb4e..ed4f0c9 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -1,4 +1,4 @@ -# Profilo Giocatore +# Modulo — Profilo Giocatore ## Obiettivo @@ -6,11 +6,9 @@ Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amminis 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 -# Utenti - -## Giocatore +### Giocatore Può: @@ -20,9 +18,7 @@ Può: - aggiornare i documenti - caricare le immagini richieste ---- - -## Amministratore +### Amministratore Può: @@ -31,11 +27,9 @@ Può: - esportare i dati necessari al tesseramento CSI - verificare lo stato di completamento dei profili ---- +## Flusso utente -# Flusso utente - -## Primo accesso +### Primo accesso 1. Login tramite Google oppure Email. 2. Selezione del proprio giocatore. @@ -43,23 +37,13 @@ Può: Se il profilo non è completo compare automaticamente un widget di completamento. ---- - -# Home +## Home Il giocatore visualizza un widget dedicato. -## Completa il tuo profilo +### Completa il tuo profilo -Viene mostrata una barra di avanzamento. - -Esempio - -Profilo completato - -85% - -La barra è composta dalle seguenti sezioni. +Viene mostrata una barra di avanzamento (esempio: *Profilo completato — 85%*), composta dalle seguenti sezioni. - Dati personali - Documento di identità @@ -68,32 +52,20 @@ La barra è composta dalle seguenti sezioni. Quando tutte le sezioni sono complete il widget scompare automaticamente. ---- - -# Profilo +## Profilo Il profilo viene suddiviso in cinque aree. -## Dati Giocatore +### Dati Giocatore -Contiene. - -### Dati squadra - -Solo lettura. +**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori. - Nome - Cognome - Numero di maglia - Ruolo -Questi dati sono gestiti esclusivamente dagli amministratori. - ---- - -### Dati personali - -Modificabili dal giocatore. +**Dati personali** — modificabili dal giocatore. - Data di nascita - Luogo di nascita @@ -101,9 +73,7 @@ Modificabili dal giocatore. - Telefono - Email ---- - -## Documento di identità +### Documento di identità Campi. @@ -118,9 +88,7 @@ Upload. - Foto fronte - Foto retro ---- - -## Certificato medico +### Certificato medico Campi. @@ -134,21 +102,15 @@ Il giocatore può aggiornare liberamente sia la data sia il file. Lo storico non viene mantenuto nella prima versione. ---- - -## Foto tessera +### Foto tessera Upload di una fotografia formato tessera. Utilizzata dagli amministratori per il tesseramento CSI. ---- +### Statistiche -## Statistiche - -Sezione già presente. - -Contiene. +Sezione già presente. Contiene. - Presenze - Voto medio @@ -156,17 +118,13 @@ Contiene. - Serie - Altre statistiche disponibili ---- - -## Badge +### Badge Sezione già presente. Contiene tutti i badge ottenuti e quelli ancora da sbloccare. ---- - -## Impostazioni +### Impostazioni Contiene. @@ -174,9 +132,7 @@ Contiene. - Preferenze notifiche - Impostazioni applicazione ---- - -# Dashboard amministratore +## Dashboard amministratore Gli amministratori dispongono di una schermata dedicata. @@ -194,9 +150,7 @@ Azioni disponibili. - Scarica documento - Scarica foto tessera ---- - -# Esportazione CSI +## Esportazione CSI Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento. @@ -215,51 +169,26 @@ Campi esportati. - Data emissione - Data scadenza ---- - -# Completamento profilo +## Completamento profilo Ogni sezione contribuisce alla percentuale di completamento. -## Pesi - -Dati personali - -30% - -Documento di identità - -30% - -Certificato medico - -30% - -Foto tessera - -10% +| 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 -# Permessi +**Giocatore** — può modificare esclusivamente il proprio profilo. -## Giocatore +**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti ed esportare i dati. -Può modificare esclusivamente il proprio profilo. - -## Amministratore - -Può visualizzare tutti i profili. - -Può scaricare tutti i documenti. - -Può esportare i dati. - ---- - -# Versione 1 +## Versione 1 - Profilo giocatore - Completamento profilo @@ -270,12 +199,10 @@ Può esportare i dati. - Dashboard amministratore - Esportazione CSV CSI ---- - -# Versioni future +## Versioni future - Storico certificati medici - Gestione documenti aggiuntivi - Consensi privacy - Firma digitale -- Verifica automatica documenti \ No newline at end of file +- 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` From ea29f053e3372bf119ef39041723f53dd2d03791 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 16:34:38 +0200 Subject: [PATCH 14/26] Enable the claude-md-management plugin for the repo Adds .claude/settings.json so every clone gets the same Claude Code plugins: the official vercel and supabase ones already in use locally, plus claude-md-management for maintaining CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .claude/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .claude/settings.json 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 + } +} From da51517ffc4b9036eccf65b31582a6b010e68b43 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 17:57:59 +0200 Subject: [PATCH 15/26] Dashboard amministratore e profilo per il tesseramento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa la voce "Dashboard amministratore" della roadmap v1.1, come descritta in docs/modules/profilo-giocatore.md, insieme alla parte di profilo che la alimenta. - /admin: stato dei profili della squadra, download di documento, certificato e foto tessera, export CSV con i 12 campi del tesseramento CSI. Un certificato scaduto non conta come valido. - Profilo giocatore: dati personali, documento (fronte e retro), certificato e foto tessera, con widget di completamento in Home che sparisce al 100%. - I permessi di amministrazione arrivano da user_roles (DD-011) e non più dalla lista di nomi in crapp-data.ts, che resta come ponte finché VITE_AUTH_OBBLIGATORIA non viene acceso in produzione. - Login Google via Supabase Auth: al primo accesso l'account si collega a uno slot libero di giocatori_squadra, e il vincolo lo fa rispettare il trigger di M1 (DD-016 regola 2). Migration additive: M2 crea profili_giocatore, M3 il bucket privato profili-giocatore. Nessuna tabella v1.0 viene toccata, quindi si possono applicare senza cambiare il comportamento attuale dell'app. supabase/config.toml e seed.sql configurano lo stack locale: serve perché il progetto Supabase è uno solo, condiviso tra sviluppo e produzione. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../crapp/ProfiloAmministrativo.tsx | 370 ++++++++++++++++++ src/components/crapp/RosaPresenze.tsx | 6 +- src/components/crapp/ScoutEntry.tsx | 5 +- .../supabase/client-nuove-tabelle.ts | 12 + src/lib/auth.ts | 61 +++ src/lib/giocatori-squadra.ts | 118 ++++++ src/lib/profili-core.ts | 205 ++++++++++ src/lib/profili.ts | 117 ++++++ src/lib/ruoli.ts | 44 +++ src/lib/scout-export.ts | 19 +- src/routeTree.gen.ts | 21 + src/routes/admin.tsx | 280 +++++++++++++ src/routes/benvenuto.tsx | 157 ++++++-- src/routes/calendario.tsx | 5 +- src/routes/eventi.tsx | 6 +- src/routes/index.tsx | 36 +- src/routes/partita.$id.tsx | 6 +- src/routes/profilo.tsx | 39 +- src/routes/scout.tsx | 6 +- supabase/config.toml | 23 +- .../20260830120000_m2_profili_giocatore.sql | 89 +++++ .../20260830120100_m3_bucket_profili.sql | 36 ++ supabase/seed.sql | 37 ++ 23 files changed, 1635 insertions(+), 63 deletions(-) create mode 100644 src/components/crapp/ProfiloAmministrativo.tsx create mode 100644 src/integrations/supabase/client-nuove-tabelle.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/giocatori-squadra.ts create mode 100644 src/lib/profili-core.ts create mode 100644 src/lib/profili.ts create mode 100644 src/lib/ruoli.ts create mode 100644 src/routes/admin.tsx create mode 100644 supabase/migrations/20260830120000_m2_profili_giocatore.sql create mode 100644 supabase/migrations/20260830120100_m3_bucket_profili.sql create mode 100644 supabase/seed.sql diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx new file mode 100644 index 0000000..1c8ad91 --- /dev/null +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -0,0 +1,370 @@ +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 } from "@/lib/profili-core"; + +const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"]; + +function Campo({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <label className="block"> + <span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground"> + {label} + </span> + <span className="mt-1 block">{children}</span> + </label> + ); +} + +const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; + +function CampoFile({ + label, + path, + sezione, + giocatoreId, + onCaricato, +}: { + label: string; + path: string | null; + sezione: SezioneFile; + giocatoreId: string; + onCaricato: (path: string) => Promise<void>; +}) { + const input = useRef<HTMLInputElement>(null); + const [inCorso, setInCorso] = useState(false); + + async function scegli(e: React.ChangeEvent<HTMLInputElement>) { + 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 ( + <div className="flex items-center justify-between gap-3 py-2"> + <span className="flex min-w-0 items-center gap-2 text-sm"> + <span + className={cn( + "grid h-6 w-6 shrink-0 place-items-center rounded-lg", + path ? "bg-success text-success-foreground" : "bg-secondary text-muted-foreground", + )} + > + {path ? <Check className="h-3.5 w-3.5" /> : <Upload className="h-3.5 w-3.5" />} + </span> + <span className="truncate">{label}</span> + </span> + <span className="flex shrink-0 items-center gap-2"> + {path ? ( + <button + type="button" + onClick={() => void scaricaFile(path)} + className="premi rounded-xl bg-secondary p-2 text-muted-foreground" + aria-label={`Vedi ${label}`} + > + <Eye className="h-4 w-4" /> + </button> + ) : null} + <button + type="button" + onClick={() => input.current?.click()} + disabled={inCorso} + className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60" + > + {inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"} + </button> + <input + ref={input} + type="file" + accept="image/jpeg,image/png,image/webp,application/pdf" + onChange={scegli} + className="hidden" + /> + </span> + </div> + ); +} + +/** + * 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<Profilo | null>(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<Profilo>) { + 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 ( + <Section + titolo="Dati per il tesseramento" + indice={indice} + azione={<span className="text-xs font-bold text-muted-foreground tabular-nums">{perc}%</span>} + > + <div className="space-y-3 rounded-3xl bg-card p-4 shadow-card"> + <div className="h-1.5 overflow-hidden rounded-full bg-secondary"> + <div + className="h-full rounded-full bg-accent-grad transition-all" + style={{ width: `${perc}%` }} + /> + </div> + + <p className="text-xs text-muted-foreground"> + Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. + </p> + + <Intestazione titolo="Dati personali" completa={sezioni.dati} /> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Data di nascita"> + <input + type="date" + value={corrente.dataNascita ?? ""} + onChange={(e) => aggiorna({ dataNascita: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Luogo di nascita"> + <input + value={corrente.luogoNascita ?? ""} + maxLength={80} + onChange={(e) => aggiorna({ luogoNascita: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + <Campo label="Indirizzo di residenza"> + <input + value={corrente.indirizzo ?? ""} + maxLength={120} + onChange={(e) => aggiorna({ indirizzo: e.target.value })} + className={classiInput} + /> + </Campo> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Telefono"> + <input + type="tel" + value={corrente.telefono ?? ""} + maxLength={20} + onChange={(e) => aggiorna({ telefono: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Email"> + <input + type="email" + value={corrente.email ?? ""} + maxLength={120} + onChange={(e) => aggiorna({ email: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + + <Intestazione titolo="Documento di identità" completa={sezioni.documento} /> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Tipo"> + <select + value={corrente.documentoTipo ?? ""} + onChange={(e) => aggiorna({ documentoTipo: e.target.value })} + className={classiInput} + > + <option value="">—</option> + {TIPI_DOCUMENTO.map((t) => ( + <option key={t} value={t}> + {t} + </option> + ))} + </select> + </Campo> + <Campo label="Numero"> + <input + value={corrente.documentoNumero ?? ""} + maxLength={40} + onChange={(e) => aggiorna({ documentoNumero: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + <Campo label="Rilasciato da"> + <input + value={corrente.documentoRilasciatoDa ?? ""} + maxLength={80} + onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })} + className={classiInput} + /> + </Campo> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Data emissione"> + <input + type="date" + value={corrente.documentoEmissione ?? ""} + onChange={(e) => aggiorna({ documentoEmissione: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Data scadenza"> + <input + type="date" + value={corrente.documentoScadenza ?? ""} + onChange={(e) => aggiorna({ documentoScadenza: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + <div className="divide-y divide-border"> + <CampoFile + label="Foto fronte" + path={corrente.documentoFrontePath} + sezione="documento-fronte" + giocatoreId={giocatoreId} + onCaricato={caricato("documentoFrontePath")} + /> + <CampoFile + label="Foto retro" + path={corrente.documentoRetroPath} + sezione="documento-retro" + giocatoreId={giocatoreId} + onCaricato={caricato("documentoRetroPath")} + /> + </div> + + <Intestazione titolo="Certificato medico" completa={sezioni.certificato} /> + <Campo label="Data di scadenza"> + <input + type="date" + value={corrente.certificatoScadenza ?? ""} + onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })} + className={classiInput} + /> + </Campo> + <CampoFile + label="Certificato medico" + path={corrente.certificatoPath} + sezione="certificato" + giocatoreId={giocatoreId} + onCaricato={caricato("certificatoPath")} + /> + + <Intestazione titolo="Foto tessera" completa={sezioni.foto} /> + <CampoFile + label="Foto tessera" + path={corrente.fotoPath} + sezione="foto" + giocatoreId={giocatoreId} + onCaricato={caricato("fotoPath")} + /> + + <button + type="button" + onClick={salvaBozza} + disabled={!sporco || salva.isPending} + className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + > + {salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} + {sporco ? "Salva" : "Salvato"} + </button> + </div> + </Section> + ); +} + +/** + * 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 ( + <Reveal indice={indice} className="px-5 pt-4"> + <Link to="/profilo" className="premi block rounded-3xl bg-card p-4 shadow-card"> + <div className="flex items-center justify-between gap-3"> + <span className="font-display text-sm uppercase tracking-wide"> + Completa il tuo profilo + </span> + <span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span> + </div> + <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary"> + <div + className="h-full rounded-full bg-accent-grad transition-all" + style={{ width: `${perc}%` }} + /> + </div> + <p className="mt-2 text-xs text-muted-foreground"> + Documento, certificato medico e foto tessera servono per il tesseramento CSI. + </p> + </Link> + </Reveal> + ); +} + +function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { + return ( + <div className="flex items-center gap-2 pt-2"> + <h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3> + {completa ? <Check className="h-4 w-4 text-success" /> : null} + </div> + ); +} 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 }) { </div> ) : null} - {io && isAdmin(io.id) ? ( + {admin ? ( <button type="button" onClick={sollecita} diff --git a/src/components/crapp/ScoutEntry.tsx b/src/components/crapp/ScoutEntry.tsx index d3ef5c1..d5452be 100644 --- a/src/components/crapp/ScoutEntry.tsx +++ b/src/components/crapp/ScoutEntry.tsx @@ -3,16 +3,17 @@ import { ChevronRight, Lock, Radio } from "lucide-react"; import { cn } from "@/lib/utils"; import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live"; import { useGiocatoreCorrente } from "@/lib/user-store"; -import { isAdmin } from "@/lib/crapp-data"; +import { useIsAdmin } from "@/lib/ruoli"; /** Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando. */ export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "compatto" }) { const { pronto, partita } = usePartitaDiOggi(); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const { data: sessione } = useSessioneScout(partita?.id ?? null); // Strumento tecnico: solo i referenti/allenatori scoutizzano la partita. - const abilitato = !!io && isAdmin(io.id); + const abilitato = admin; const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null; const occupato = !!attiva && attiva.giocatore_id !== io?.id; diff --git a/src/integrations/supabase/client-nuove-tabelle.ts b/src/integrations/supabase/client-nuove-tabelle.ts new file mode 100644 index 0000000..5b6a781 --- /dev/null +++ b/src/integrations/supabase/client-nuove-tabelle.ts @@ -0,0 +1,12 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { supabase } from "./client"; + +/** + * `types.ts` è generato dallo schema e non include ancora le tabelle introdotte dalle + * migration M1 (`giocatori_squadra`) e M2 (`profili_giocatore`). Finché non viene + * rigenerato si passa da qui: i tipi delle righe sono dichiarati nei moduli di `src/lib/`, + * che restano l'unico punto di accesso al database (DD-013). + * + * Da eliminare quando `types.ts` sarà rigenerato: i moduli torneranno a usare `supabase`. + */ +export const supabaseNuoveTabelle = supabase as unknown as SupabaseClient; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..307d174 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import type { Session } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +/** + * Autenticazione reale con Google (DD-011). Il login non ha ancora sostituito la + * selezione del giocatore: finché `VITE_AUTH_OBBLIGATORIA` non è `true`, `/benvenuto` + * offre entrambe le strade, così la produzione continua a funzionare mentre la squadra + * collega gli account. + */ +export function authObbligatoria(): boolean { + return import.meta.env["VITE_AUTH_OBBLIGATORIA"] === "true"; +} + +export function useSessione() { + const [sessione, setSessione] = useState<Session | null>(null); + const [pronta, setPronta] = useState(false); + + useEffect(() => { + let attivo = true; + // Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente: + // qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e + // resta irraggiungibile anche la selezione del giocatore. + try { + supabase.auth + .getSession() + .then(({ data }) => { + if (!attivo) return; + setSessione(data.session); + setPronta(true); + }) + .catch(() => attivo && setPronta(true)); + const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova)); + return () => { + attivo = false; + data.subscription.unsubscribe(); + }; + } catch (errore) { + console.error("[auth] Supabase non disponibile", errore); + setPronta(true); + return () => { + attivo = false; + }; + } + }, []); + + return { sessione, pronta, utenteId: sessione?.user.id ?? null }; +} + +export async function accediConGoogle(): Promise<void> { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { redirectTo: window.location.origin }, + }); + if (error) throw error; +} + +export async function esci(): Promise<void> { + const { error } = await supabase.auth.signOut(); + if (error) throw error; +} diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts new file mode 100644 index 0000000..ca16cc3 --- /dev/null +++ b/src/lib/giocatori-squadra.ts @@ -0,0 +1,118 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; +import { giocatori } from "./crapp-data"; + +/** + * Anagrafica operativa della squadra (`giocatori_squadra`, migration M1). È la source of + * truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché + * la migrazione non è completa (DD-016 regola 1). + */ +export type GiocatoreSquadra = { + id: string; + nome: string; + cognome: string; + numero: number; + ruolo: string; + authUserId: string | null; + attivo: boolean; +}; + +type RigaGiocatoreSquadra = { + id: string; + nome: string; + cognome: string; + numero: number; + ruolo: string; + auth_user_id: string | null; + attivo: boolean; +}; + +export const SQUADRA_KEY = ["giocatori-squadra"] as const; + +/** "Carlo Di Castelnuovo" -> nome "Carlo", cognome "Di Castelnuovo". */ +export function dividiNome(completo: string): { nome: string; cognome: string } { + const spazio = completo.indexOf(" "); + if (spazio < 0) return { nome: completo, cognome: "" }; + return { nome: completo.slice(0, spazio), cognome: completo.slice(spazio + 1) }; +} + +/** Rosa di riserva quando il database non risponde o non è ancora popolato. */ +export function rosaFallback(): GiocatoreSquadra[] { + return giocatori.map((g) => ({ + ...dividiNome(g.nome), + id: g.id, + numero: g.numero, + ruolo: g.ruolo, + authUserId: null, + attivo: true, + })); +} + +export function nomeCompleto(g: GiocatoreSquadra): string { + return `${g.nome} ${g.cognome}`.trim(); +} + +/** Lo slot già collegato a questo account, se esiste. */ +export function slotDi( + righe: GiocatoreSquadra[], + utenteId: string | null, +): GiocatoreSquadra | null { + if (!utenteId) return null; + return righe.find((g) => g.authUserId === utenteId) ?? null; +} + +export function slotLiberi(righe: GiocatoreSquadra[]): GiocatoreSquadra[] { + return righe.filter((g) => g.attivo && !g.authUserId); +} + +async function fetchSquadra(): Promise<GiocatoreSquadra[]> { + const { data, error } = await supabaseNuoveTabelle + .from("giocatori_squadra") + .select("id, nome, cognome, numero, ruolo, auth_user_id, attivo") + .order("id"); + if (error) throw error; + const righe = (data ?? []) as RigaGiocatoreSquadra[]; + return righe.map((r) => ({ + id: r.id, + nome: r.nome, + cognome: r.cognome, + numero: r.numero, + ruolo: r.ruolo, + authUserId: r.auth_user_id, + attivo: r.attivo, + })); +} + +/** Anagrafica squadra: una lettura per sessione, cambia raramente. */ +export function useGiocatoriSquadra() { + const query = useQuery({ queryKey: SQUADRA_KEY, queryFn: fetchSquadra, staleTime: 30 * 60_000 }); + const righe = query.data?.length ? query.data : rosaFallback(); + return { ...query, righe, daDatabase: !!query.data?.length }; +} + +/** + * Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se + * lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel + * database, non qui. + */ +export function useCollegaGiocatore() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { giocatoreId: string; utenteId: string }) => { + const { error } = await supabaseNuoveTabelle + .from("giocatori_squadra") + .update({ auth_user_id: input.utenteId }) + .eq("id", input.giocatoreId) + .is("auth_user_id", null); + if (error) throw error; + return input; + }, + onSuccess: (input) => { + queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) => + (prec ?? []).map((g) => + g.id === input.giocatoreId ? { ...g, authUserId: input.utenteId } : g, + ), + ); + }, + }); +} diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts new file mode 100644 index 0000000..ae40c07 --- /dev/null +++ b/src/lib/profili-core.ts @@ -0,0 +1,205 @@ +import { rigaCsv } from "./scout-export"; +import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra"; + +/** + * Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato + * `profili-giocatore`: qui viaggiano solo i path. + */ +export type Profilo = { + giocatoreId: string; + dataNascita: string | null; + luogoNascita: string | null; + indirizzo: string | null; + telefono: string | null; + email: string | null; + documentoTipo: string | null; + documentoNumero: string | null; + documentoRilasciatoDa: string | null; + documentoEmissione: string | null; + documentoScadenza: string | null; + documentoFrontePath: string | null; + documentoRetroPath: string | null; + certificatoScadenza: string | null; + certificatoPath: string | null; + fotoPath: string | null; +}; + +export type RigaProfilo = { + giocatore_id: string; + data_nascita: string | null; + luogo_nascita: string | null; + indirizzo: string | null; + telefono: string | null; + email: string | null; + documento_tipo: string | null; + documento_numero: string | null; + documento_rilasciato_da: string | null; + documento_emissione: string | null; + documento_scadenza: string | null; + documento_fronte_path: string | null; + documento_retro_path: string | null; + certificato_scadenza: string | null; + certificato_path: string | null; + foto_path: string | null; +}; + +export const COLONNE_PROFILO = + "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"; + +export function profiloVuoto(giocatoreId: string): Profilo { + return { + giocatoreId, + 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, + }; +} + +export function daRigaProfilo(r: RigaProfilo): Profilo { + return { + giocatoreId: r.giocatore_id, + dataNascita: r.data_nascita, + luogoNascita: r.luogo_nascita, + indirizzo: r.indirizzo, + telefono: r.telefono, + email: r.email, + documentoTipo: r.documento_tipo, + documentoNumero: r.documento_numero, + documentoRilasciatoDa: r.documento_rilasciato_da, + documentoEmissione: r.documento_emissione, + documentoScadenza: r.documento_scadenza, + documentoFrontePath: r.documento_fronte_path, + documentoRetroPath: r.documento_retro_path, + certificatoScadenza: r.certificato_scadenza, + certificatoPath: r.certificato_path, + fotoPath: r.foto_path, + }; +} + +/** I campi vuoti tornano al database come NULL, non come stringa vuota. */ +function oNull(valore: string | null): string | null { + const pulito = valore?.trim(); + return pulito ? pulito : null; +} + +export function aRigaProfilo(p: Profilo): RigaProfilo { + return { + giocatore_id: p.giocatoreId, + data_nascita: oNull(p.dataNascita), + luogo_nascita: oNull(p.luogoNascita), + indirizzo: oNull(p.indirizzo), + telefono: oNull(p.telefono), + email: oNull(p.email), + documento_tipo: oNull(p.documentoTipo), + documento_numero: oNull(p.documentoNumero), + documento_rilasciato_da: oNull(p.documentoRilasciatoDa), + documento_emissione: oNull(p.documentoEmissione), + documento_scadenza: oNull(p.documentoScadenza), + documento_fronte_path: oNull(p.documentoFrontePath), + documento_retro_path: oNull(p.documentoRetroPath), + certificato_scadenza: oNull(p.certificatoScadenza), + certificato_path: oNull(p.certificatoPath), + foto_path: oNull(p.fotoPath), + }; +} + +/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */ +export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const; + +export type Sezione = keyof typeof PESI; + +export function sezioniComplete(p: Profilo | null | undefined): Record<Sezione, boolean> { + return { + dati: !!(p?.dataNascita && p.luogoNascita && p.indirizzo && p.telefono && p.email), + documento: !!( + p?.documentoTipo && + p.documentoNumero && + p.documentoScadenza && + p.documentoFrontePath && + p.documentoRetroPath + ), + certificato: !!(p?.certificatoScadenza && p.certificatoPath), + foto: !!p?.fotoPath, + }; +} + +/** Percentuale di completamento: calcolata a runtime, mai persistita (DD-007, DD-016). */ +export function completamento(p: Profilo | null | undefined): number { + const complete = sezioniComplete(p); + return (Object.keys(PESI) as Sezione[]).reduce( + (somma, s) => somma + (complete[s] ? PESI[s] : 0), + 0, + ); +} + +export type StatoScadenza = "mancante" | "scaduto" | "valido"; + +/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */ +export function statoScadenza( + scadenza: string | null | undefined, + path: string | null | undefined, + oggi: string, +): StatoScadenza { + if (!path || !scadenza) return "mancante"; + return scadenza < oggi ? "scaduto" : "valido"; +} + +/** Colonne richieste dal tesseramento CSI, nell'ordine del documento di modulo. */ +const INTESTAZIONI = [ + "Nome", + "Cognome", + "Data di nascita", + "Luogo di nascita", + "Indirizzo", + "Telefono", + "Email", + "Tipo documento", + "Numero documento", + "Rilasciato da", + "Data emissione", + "Data scadenza", +]; + +export function csvTesseramento( + squadra: GiocatoreSquadra[], + profili: Record<string, Profilo>, +): string { + const righe = [rigaCsv(INTESTAZIONI)]; + for (const g of squadra) { + const p = profili[g.id]; + righe.push( + rigaCsv([ + g.nome, + g.cognome, + p?.dataNascita ?? "", + p?.luogoNascita ?? "", + p?.indirizzo ?? "", + p?.telefono ?? "", + p?.email ?? "", + p?.documentoTipo ?? "", + p?.documentoNumero ?? "", + p?.documentoRilasciatoDa ?? "", + p?.documentoEmissione ?? "", + p?.documentoScadenza ?? "", + ]), + ); + } + return righe.join("\n"); +} + +/** Etichetta per l'elenco della dashboard. */ +export function etichettaGiocatore(g: GiocatoreSquadra): string { + return `#${g.numero} ${nomeCompleto(g)}`; +} diff --git a/src/lib/profili.ts b/src/lib/profili.ts new file mode 100644 index 0000000..e73d7ef --- /dev/null +++ b/src/lib/profili.ts @@ -0,0 +1,117 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; +import { + aRigaProfilo, + COLONNE_PROFILO, + daRigaProfilo, + type Profilo, + type RigaProfilo, +} from "./profili-core"; + +export const PROFILI_KEY = ["profili-giocatore"] as const; +export const BUCKET = "profili-giocatore"; + +async function fetchProfili(): Promise<Record<string, Profilo>> { + const { data, error } = await supabaseNuoveTabelle + .from("profili_giocatore") + .select(COLONNE_PROFILO); + if (error) throw error; + const mappa: Record<string, Profilo> = {}; + for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r); + return mappa; +} + +/** + * Profili visibili all'utente corrente: le policy RLS decidono quanti sono — il proprio + * per un giocatore, tutti per un admin. Una lettura per sessione. + */ +export function useProfili() { + const query = useQuery({ queryKey: PROFILI_KEY, queryFn: fetchProfili, staleTime: 30 * 60_000 }); + return { ...query, profili: query.data ?? {} }; +} + +/** + * I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4): + * ogni download passa da una signed URL che scade in un minuto. + */ +export async function urlFirmato(path: string): Promise<string> { + const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60); + if (error) throw error; + return data.signedUrl; +} + +export async function scaricaFile(path: string): Promise<void> { + const url = await urlFirmato(path); + window.open(url, "_blank", "noopener,noreferrer"); +} + +/** + * Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga: + * il vincolo vive nel database, qui non serve ricontrollarlo. + */ +export function useSalvaProfilo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (profilo: Profilo) => { + const { error } = await supabaseNuoveTabelle + .from("profili_giocatore") + .upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" }); + if (error) throw error; + return profilo; + }, + // Scrittura unica e cache aggiornata a mano, senza rilettura. + onSuccess: (profilo) => { + queryClient.setQueryData<Record<string, Profilo>>(PROFILI_KEY, (prec) => ({ + ...(prec ?? {}), + [profilo.giocatoreId]: profilo, + })); + }, + }); +} + +export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto"; + +const MAX_BYTE = 8 * 1024 * 1024; +const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"]; + +function estensione(nome: string): string { + const punto = nome.lastIndexOf("."); + const est = punto > 0 ? nome.slice(punto + 1).toLowerCase() : ""; + return /^[a-z0-9]{1,5}$/.test(est) ? est : "bin"; +} + +/** + * Carica un file nella cartella del giocatore e restituisce il path da salvare sul profilo. + * Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto + * dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla + * propria cartella. + */ +export async function caricaFile( + giocatoreId: string, + sezione: SezioneFile, + file: File, + pathPrecedente?: string | null, +): Promise<string> { + if (!TIPI_AMMESSI.includes(file.type)) { + throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF."); + } + if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB."); + + const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`; + const { error } = await supabase.storage + .from(BUCKET) + .upload(path, file, { upsert: true, contentType: file.type }); + if (error) throw error; + + // Cambiando estensione il vecchio file resterebbe orfano nel bucket. + if (pathPrecedente && pathPrecedente !== path) { + await supabase.storage.from(BUCKET).remove([pathPrecedente]); + } + return path; +} + +export async function rimuoviFile(path: string): Promise<void> { + const { error } = await supabase.storage.from(BUCKET).remove([path]); + if (error) throw error; +} diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts new file mode 100644 index 0000000..e37b816 --- /dev/null +++ b/src/lib/ruoli.ts @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { isAdmin as nomeInListaAdmin } from "./crapp-data"; +import { useSessione } from "./auth"; +import { useGiocatoreBase } from "./user-store"; + +export const RUOLI_KEY = ["ruolo-admin"] as const; + +/** + * Permessi di amministrazione. La fonte è `user_roles` nel database (DD-011): la lista di + * nomi in `crapp-data.ts` resta solo come ponte per chi non ha ancora collegato l'account, + * e sparisce quando `VITE_AUTH_OBBLIGATORIA` viene acceso in produzione. + * + * ponytail: doppia fonte temporanea, si riduce a `ruoloDb` appena l'auth è obbligatoria. + */ +export function risolviAdmin(ruoloDb: boolean | null, giocatoreId: string | null): boolean { + if (ruoloDb !== null) return ruoloDb; + return giocatoreId ? nomeInListaAdmin(giocatoreId) : false; +} + +/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */ +async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> { + if (!utenteId) return null; + const { data, error } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", utenteId) + .eq("role", "admin") + .maybeSingle(); + if (error) throw error; + return !!data; +} + +export function useIsAdmin(): boolean { + const { utenteId } = useSessione(); + const io = useGiocatoreBase(); + // Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. + const query = useQuery({ + queryKey: [...RUOLI_KEY, utenteId], + queryFn: () => fetchRuoloAdmin(utenteId), + staleTime: 30 * 60_000, + }); + return risolviAdmin(query.data ?? null, io?.id ?? null); +} diff --git a/src/lib/scout-export.ts b/src/lib/scout-export.ts index 843d9ef..ae46931 100644 --- a/src/lib/scout-export.ts +++ b/src/lib/scout-export.ts @@ -1,7 +1,8 @@ import { giocatori } from "./crapp-data"; import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; -function riga(campi: Array<string | number>) { +/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */ +export function rigaCsv(campi: Array<string | number>) { return campi .map((c) => { const testo = String(c); @@ -13,24 +14,24 @@ function riga(campi: Array<string | number>) { /** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */ export function csvScoutMatch(match: ScoutMatch): string { const righe: string[] = []; - righe.push(riga(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"])); - righe.push(riga(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`])); + righe.push(rigaCsv(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"])); + righe.push(rigaCsv(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`])); righe.push(""); - righe.push(riga(["Set", "Parziale nostro", "Parziale loro"])); - match.parziali.forEach((p, i) => righe.push(riga([i + 1, p[0], p[1]]))); + righe.push(rigaCsv(["Set", "Parziale nostro", "Parziale loro"])); + match.parziali.forEach((p, i) => righe.push(rigaCsv([i + 1, p[0], p[1]]))); righe.push(""); - righe.push(riga(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"])); + righe.push(rigaCsv(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"])); const totali = totaliPerGiocatore(match.azioni); for (const g of giocatori) { const t = totali.get(g.id); if (!t) continue; - righe.push(riga([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); + righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); } righe.push(""); - righe.push(riga(["Set", "Giocatore", "Azione"])); + righe.push(rigaCsv(["Set", "Giocatore", "Azione"])); for (const a of match.azioni) { const g = giocatori.find((x) => x.id === a.giocatoreId); - righe.push(riga([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label])); + righe.push(rigaCsv([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label])); } return righe.join("\n"); } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a7a0bda..3378673 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as AdminRouteImport } from './routes/admin' import { Route as BenvenutoRouteImport } from './routes/benvenuto' import { Route as CalendarioRouteImport } from './routes/calendario' import { Route as ClassificaRouteImport } from './routes/classifica' @@ -31,6 +32,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) const BenvenutoRoute = BenvenutoRouteImport.update({ id: '/benvenuto', path: '/benvenuto', @@ -111,6 +117,7 @@ const ApiPublicSollecitaPresenzeRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/admin': typeof AdminRoute '/benvenuto': typeof BenvenutoRoute '/calendario': typeof CalendarioRoute '/classifica': typeof ClassificaRoute @@ -129,6 +136,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/admin': typeof AdminRoute '/benvenuto': typeof BenvenutoRoute '/calendario': typeof CalendarioRoute '/classifica': typeof ClassificaRoute @@ -148,6 +156,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/admin': typeof AdminRoute '/benvenuto': typeof BenvenutoRoute '/calendario': typeof CalendarioRoute '/classifica': typeof ClassificaRoute @@ -168,6 +177,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/admin' | '/benvenuto' | '/calendario' | '/classifica' @@ -186,6 +196,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/admin' | '/benvenuto' | '/calendario' | '/classifica' @@ -204,6 +215,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/admin' | '/benvenuto' | '/calendario' | '/classifica' @@ -223,6 +235,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRoute BenvenutoRoute: typeof BenvenutoRoute CalendarioRoute: typeof CalendarioRoute ClassificaRoute: typeof ClassificaRoute @@ -249,6 +262,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } '/benvenuto': { id: '/benvenuto' path: '/benvenuto' @@ -359,6 +379,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AdminRoute: AdminRoute, BenvenutoRoute: BenvenutoRoute, CalendarioRoute: CalendarioRoute, ClassificaRoute: ClassificaRoute, diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx new file mode 100644 index 0000000..7511cdc --- /dev/null +++ b/src/routes/admin.tsx @@ -0,0 +1,280 @@ +import { useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { ChevronDown, Download, FileText, IdCard, Image, Loader2, Lock } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; +import { useGiocatoriSquadra, nomeCompleto } from "@/lib/giocatori-squadra"; +import { useProfili, scaricaFile } from "@/lib/profili"; +import { + completamento, + csvTesseramento, + sezioniComplete, + statoScadenza, + type Profilo, + type StatoScadenza, +} from "@/lib/profili-core"; +import { oggiISO } from "@/lib/palloni-core"; +import { scaricaCsv } from "@/lib/scout-export"; +import { useIsAdmin } from "@/lib/ruoli"; +import { Reveal } from "@/components/motion/Reveal"; + +export const Route = createFileRoute("/admin")({ + head: () => ({ + meta: [ + { title: "Dashboard amministratore — CrAPP" }, + { + name: "description", + content: + "Area riservata: stato dei profili, documenti e certificati della squadra, ed export dei dati per il tesseramento CSI.", + }, + { property: "og:title", content: "Dashboard amministratore — CrAPP" }, + { + property: "og:description", + content: "Stato dei profili della squadra ed export per il tesseramento CSI.", + }, + ], + }), + component: Dashboard, +}); + +const statoClasse: Record<StatoScadenza | "presente" | "assente", string> = { + valido: "bg-success text-success-foreground", + presente: "bg-success text-success-foreground", + scaduto: "bg-destructive text-destructive-foreground", + mancante: "bg-secondary text-muted-foreground", + assente: "bg-secondary text-muted-foreground", +}; + +function Riga({ etichetta, valore }: { etichetta: string; valore: string | null }) { + return ( + <div className="flex items-baseline justify-between gap-3 py-1 text-sm"> + <span className="shrink-0 text-muted-foreground">{etichetta}</span> + <span className="truncate text-right font-medium">{valore || "—"}</span> + </div> + ); +} + +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 ( + <button + type="button" + onClick={scarica} + disabled={!path || inCorso} + className={cn( + "premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold uppercase disabled:opacity-60", + statoClasse[stato], + )} + > + {inCorso ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : icona} + {label} + {path ? <Download className="h-3 w-3" /> : null} + </button> + ); +} + +function SchedaGiocatore({ + nome, + ruolo, + numero, + profilo, + oggi, + indice, +}: { + nome: string; + ruolo: string; + numero: number; + profilo: Profilo | undefined; + oggi: string; + indice: number; +}) { + const [aperta, setAperta] = useState(false); + 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 ( + <Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card"> + <button + type="button" + onClick={() => setAperta((v) => !v)} + className="flex w-full items-center gap-3 text-left" + > + <div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums"> + {numero} + </div> + <div className="min-w-0 flex-1"> + <p className="truncate font-semibold leading-tight">{nome}</p> + <p className="text-xs text-muted-foreground"> + {ruolo} · profilo {perc}% + </p> + </div> + <ChevronDown + className={cn( + "h-4 w-4 shrink-0 text-muted-foreground transition-transform", + aperta && "rotate-180", + )} + /> + </button> + + <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-secondary"> + <div + className="h-full rounded-full bg-accent-grad transition-all" + style={{ width: `${perc}%` }} + /> + </div> + + <div className="mt-3 flex flex-wrap gap-2"> + <Documento + icona={<IdCard className="h-3.5 w-3.5" />} + label="Doc fronte" + stato={fronte} + path={profilo?.documentoFrontePath ?? null} + /> + <Documento + icona={<IdCard className="h-3.5 w-3.5" />} + label="Doc retro" + stato={retro} + path={profilo?.documentoRetroPath ?? null} + /> + <Documento + icona={<FileText className="h-3.5 w-3.5" />} + label="Certificato" + stato={certificato} + path={profilo?.certificatoPath ?? null} + /> + <Documento + icona={<Image className="h-3.5 w-3.5" />} + label="Foto" + stato={sezioni.foto ? "presente" : "assente"} + path={profilo?.fotoPath ?? null} + /> + </div> + + {aperta ? ( + <div className="mt-3 border-t border-border pt-3"> + <Riga etichetta="Data di nascita" valore={profilo?.dataNascita ?? null} /> + <Riga etichetta="Luogo di nascita" valore={profilo?.luogoNascita ?? null} /> + <Riga etichetta="Indirizzo" valore={profilo?.indirizzo ?? null} /> + <Riga etichetta="Telefono" valore={profilo?.telefono ?? null} /> + <Riga etichetta="Email" valore={profilo?.email ?? null} /> + <Riga + etichetta="Documento" + valore={ + profilo?.documentoNumero + ? `${profilo.documentoTipo ?? ""} ${profilo.documentoNumero}`.trim() + : null + } + /> + <Riga etichetta="Rilasciato da" valore={profilo?.documentoRilasciatoDa ?? null} /> + <Riga etichetta="Scadenza documento" valore={profilo?.documentoScadenza ?? null} /> + <Riga etichetta="Scadenza certificato" valore={profilo?.certificatoScadenza ?? null} /> + </div> + ) : null} + </Reveal> + ); +} + +function Dashboard() { + const admin = useIsAdmin(); + const { righe: squadra } = useGiocatoriSquadra(); + const { profili, isPending } = useProfili(); + const oggi = oggiISO(); + + if (!admin) { + return ( + <> + <PageHeader titolo="Dashboard" sottotitolo="Area riservata" /> + <div className="px-5 pt-4"> + <p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card"> + <Lock className="h-4 w-4 shrink-0" /> + Riservata agli amministratori della squadra. + </p> + </div> + </> + ); + } + + 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 ( + <> + <PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" /> + + <Section titolo="Squadra"> + <div className="grid grid-cols-3 gap-2"> + <StatTile valore={attivi.length} label="Giocatori" /> + <StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" /> + <StatTile + valore={`${certificatiOk}/${attivi.length}`} + label="Certificati validi" + hint="non scaduti" + /> + </div> + <button + type="button" + onClick={() => + scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili)) + } + className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop" + > + <Download className="h-4 w-4" /> Esporta CSV tesseramento + </button> + </Section> + + <Section titolo="Profili" indice={1}> + {isPending ? ( + <p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card"> + Caricamento… + </p> + ) : ( + <div className="space-y-3"> + {attivi.map((g, i) => ( + <SchedaGiocatore + key={g.id} + nome={nomeCompleto(g)} + ruolo={g.ruolo} + numero={g.numero} + profilo={profili[g.id]} + oggi={oggi} + indice={i} + /> + ))} + </div> + )} + </Section> + </> + ); +} diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index 865e66f..d4ab6cd 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -1,7 +1,18 @@ 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 { accediConGoogle, authObbligatoria, useSessione } from "@/lib/auth"; +import { + nomeCompleto, + slotDi, + slotLiberi, + useCollegaGiocatore, + useGiocatoriSquadra, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; export const Route = createFileRoute("/benvenuto")({ @@ -10,27 +21,89 @@ export const Route = createFileRoute("/benvenuto")({ { 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 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 ( + <button + type="button" + onClick={onClick} + className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]" + > + <div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg"> + {iniziali} + </div> + <div className="min-w-0 flex-1 text-left"> + <p className="font-semibold leading-tight">{titolo}</p> + <p className="text-xs text-muted-foreground">{sottotitolo}</p> + </div> + </button> + ); +} + function Benvenuto() { const navigate = useNavigate(); const giocatore = useGiocatoreCorrente(); + const { pronta, utenteId } = useSessione(); + const { righe } = useGiocatoriSquadra(); + const collega = useCollegaGiocatore(); + const [inCorso, setInCorso] = useState(false); + + const mioSlot = slotDi(righe, utenteId); + // Con l'auth obbligatoria si entra solo da loggati; finché non lo è, la selezione + // diretta resta come ponte per chi non ha ancora collegato l'account (DD-011). + const puoEntrare = !!giocatore && (!!utenteId || !authObbligatoria()); useEffect(() => { - if (giocatore) { - navigate({ to: "/" }); + if (puoEntrare) navigate({ to: "/" }); + }, [puoEntrare, navigate]); + + // L'account è già collegato a uno slot: nessuna scelta da fare. + useEffect(() => { + if (mioSlot) impostaGiocatore(mioSlot.id); + }, [mioSlot]); + + 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 ( <div className="flex min-h-screen flex-col items-center justify-center px-6 py-12"> @@ -38,29 +111,63 @@ function Benvenuto() { <h1 className="mt-6 text-center font-display text-4xl uppercase leading-none"> Benvenuto in CrAPP DEVELOP </h1> - <p className="mt-2 text-center text-sm text-muted-foreground"> - Seleziona chi sei per personalizzare l'app. - </p> - <div className="mt-8 w-full max-w-sm space-y-2"> - {giocatori.map((g) => ( + + {!pronta ? null : !utenteId ? ( + <> + <p className="mt-2 text-center text-sm text-muted-foreground"> + Accedi con il tuo account Google per collegare il profilo giocatore. + </p> <button - key={g.id} type="button" - onClick={() => impostaGiocatore(g.id)} - className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]" + onClick={accedi} + disabled={inCorso} + className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60" > - <div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg"> - {g.iniziali} - </div> - <div className="min-w-0 flex-1 text-left"> - <p className="font-semibold leading-tight">{g.nome}</p> - <p className="text-xs text-muted-foreground"> - #{g.numero} · {g.ruolo} - </p> - </div> + <LogIn className="h-4 w-4" /> Accedi con Google </button> - ))} - </div> + + {authObbligatoria() ? null : ( + <div className="mt-10 w-full max-w-sm"> + <p className="mb-3 text-center text-xs text-muted-foreground"> + Oppure entra scegliendo il tuo nome, come prima. + </p> + <div className="space-y-2"> + {giocatori.map((g) => ( + <Scheda + key={g.id} + titolo={g.nome} + sottotitolo={`#${g.numero} · ${g.ruolo}`} + iniziali={g.iniziali} + onClick={() => impostaGiocatore(g.id)} + /> + ))} + </div> + </div> + )} + </> + ) : ( + <> + <p className="mt-2 text-center text-sm text-muted-foreground"> + Sei entrato. Scegli il tuo nome: resterà collegato a questo account. + </p> + <div className="mt-8 w-full max-w-sm space-y-2"> + {liberi.map((g) => ( + <Scheda + key={g.id} + titolo={nomeCompleto(g)} + sottotitolo={`#${g.numero} · ${g.ruolo}`} + iniziali={`${g.nome[0] ?? ""}${g.cognome[0] ?? ""}`.toUpperCase()} + onClick={() => void reclama(g)} + /> + ))} + {liberi.length === 0 ? ( + <p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card"> + Nessun profilo libero: chiedi a un amministratore di collegarti. + </p> + ) : null} + </div> + </> + )} </div> ); } 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<number | null>(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() { </Section> ) : null} - {io && isAdmin(io.id) ? ( + {admin ? ( <div className="px-5 pt-4"> <Link to="/eventi" 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<Evento | null>(null); - if (!io || !isAdmin(io.id)) { + if (!io || !admin) { return ( <> <PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" /> 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() { </div> </Section> + <CompletaProfilo giocatoreId={giocatore.id} indice={2} /> + <Section titolo="Da confermare" indice={2}> <div className="space-y-3"> {prossimi.slice(1).map((e) => { @@ -155,25 +159,29 @@ function Index() { titolo="Obiettivo di squadra" indice={4} azione={ - <Link to="/squadra" className="inline-flex items-center text-xs font-semibold text-accent"> + <Link + to="/squadra" + className="inline-flex items-center text-xs font-semibold text-accent" + > Tutti <ChevronRight className="h-3 w-3" /> </Link> } > {obiettivo ? ( - <div className="premi rounded-3xl bg-card p-4 shadow-card"> - <div className="flex items-center gap-2 text-sm font-bold"> - <span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo} + <div className="premi rounded-3xl bg-card p-4 shadow-card"> + <div className="flex items-center gap-2 text-sm font-bold"> + <span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo} + </div> + <Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" /> + <p className="mt-2 text-xs text-muted-foreground"> + Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "} + {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. + </p> + <p className="mt-1 text-xs font-semibold text-accent"> + {microcopyObiettivo(obiettivo)} + </p> + <p className="mt-1 text-[11px] text-muted-foreground">{obiettivo.impatto}</p> </div> - <Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" /> - <p className="mt-2 text-xs text-muted-foreground"> - Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "} - {obiettivo.valore}/{obiettivo.target}{" "} - {obiettivo.unita}. - </p> - <p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(obiettivo)}</p> - <p className="mt-1 text-[11px] text-muted-foreground">{obiettivo.impatto}</p> - </div> ) : null} </Section> 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() { ); })} </div> - {io && isAdmin(io.id) ? ( + {admin ? ( <button type="button" onClick={() => scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout))} diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 29a05e5..91fafa2 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,13 +1,14 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { Flame, Camera, Users, Trash2, Bell } from "lucide-react"; +import { Flame, Camera, Users, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; import { Avatar } from "@/components/crapp/Avatar"; import { fileToAvatar, salvaAvatar, rimuoviAvatar, useAvatar } from "@/lib/avatar-store"; import { SerieGriglia } from "@/components/crapp/SerieCard"; import { CollezioneBadge } from "@/components/crapp/CollezioneBadge"; +import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo"; import { useVotiSocial } from "@/lib/badge-social"; import { useIo } from "@/lib/rosa"; import { usePresenzeUltimoMese } from "@/lib/presenze-mese"; @@ -18,6 +19,8 @@ import { statoNotifiche, } from "@/lib/push-client"; import { resetGiocatore } from "@/lib/user-store"; +import { esci, useSessione } from "@/lib/auth"; +import { useIsAdmin } from "@/lib/ruoli"; import { Reveal } from "@/components/motion/Reveal"; export const Route = createFileRoute("/profilo")({ @@ -38,6 +41,8 @@ export const Route = createFileRoute("/profilo")({ function Profilo() { const votiSocial = useVotiSocial(); const g = useIo(); + const admin = useIsAdmin(); + const { sessione } = useSessione(); const ultimoMese = usePresenzeUltimoMese(g?.id); const inputRef = useRef<HTMLInputElement>(null); const foto = useAvatar(g?.id); @@ -72,6 +77,15 @@ function Profilo() { } } + async function logout() { + try { + await esci(); + resetGiocatore(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Uscita non riuscita"); + } + } + const percPresenze = Math.round((g.presenze / g.totaliEventi) * 100); async function onFile(e: React.ChangeEvent<HTMLInputElement>) { @@ -168,6 +182,8 @@ function Profilo() { <CollezioneBadge g={g} votiSocial={votiSocial.data ?? []} /> </Section> + <ProfiloAmministrativo giocatoreId={g.id} indice={4} /> + <Section titolo="Impostazioni"> <div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card"> <button @@ -203,6 +219,15 @@ function Profilo() { </label> ), )} + {admin ? ( + <Link + to="/admin" + className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5" + > + <span className="min-w-0 truncate">Dashboard amministratore</span> + <ShieldCheck className="h-4 w-4 text-muted-foreground" /> + </Link> + ) : null} <button type="button" onClick={() => resetGiocatore()} @@ -211,6 +236,16 @@ function Profilo() { <span className="min-w-0 truncate">Cambia giocatore</span> <Users className="h-4 w-4 text-muted-foreground" /> </button> + {sessione ? ( + <button + type="button" + onClick={logout} + className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5" + > + <span className="min-w-0 truncate">Esci</span> + <LogOut className="h-4 w-4 text-muted-foreground" /> + </button> + ) : null} </div> </Section> </> 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 ( <Blocco icona={<Lock className="h-6 w-6" />} diff --git a/supabase/config.toml b/supabase/config.toml index e0800f3..30be195 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1,22 @@ -project_id = "kfkcldwncxqaixetsjes" \ 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/20260830120000_m2_profili_giocatore.sql b/supabase/migrations/20260830120000_m2_profili_giocatore.sql new file mode 100644 index 0000000..97061bb --- /dev/null +++ b/supabase/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/supabase/migrations/20260830120100_m3_bucket_profili.sql b/supabase/migrations/20260830120100_m3_bucket_profili.sql new file mode 100644 index 0000000..4a26afd --- /dev/null +++ b/supabase/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: `<giocatore_id>/<sezione>.<estensione>` (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/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 = '<la tua mail>'; From 255afde48e6ae2d9e5816057abdbc8d4c8b24f85 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 17:58:12 +0200 Subject: [PATCH 16/26] Test per profili, ruoli e permessi sul database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copre le parti nuove e una trappola che sarebbe passata inosservata. - unit: completamento del profilo (30/30/30/10), stato di scadenza dei documenti, export CSV, conversione riga <-> modello, anagrafica di squadra. - unit: risoluzione dei permessi admin, incluso il fatto che con una sessione attiva decide il database e la lista di nomi non conta più. - integration (schema-profili): verifica su un database vero le colonne che il codice legge, il bucket privato e la chiusura verso l'utente anonimo. - e2e: /admin entra nell'elenco delle schermate verificate. schema-profili tenta scritture da anonimo per dimostrare che la RLS le respinge, e poi rilegge la riga: su un UPDATE che tocca zero righe PostgREST risponde 2xx, quindi fidarsi del codice di risposta darebbe un falso verde. Si salta da solo dove M2/M3 non sono ancora applicate, indicandolo nel motivo. Verificato: 8/8 contro lo stack locale (che ha M2/M3), 21/21 file con npm run test:all contro il progetto cloud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- test/README.md | 9 +- test/e2e/percorsi.test.ts | 1 + test/integration/schema-profili.test.ts | 140 ++++++++++++++++++++ test/unit/profili-core.test.ts | 165 ++++++++++++++++++++++++ test/unit/ruoli.test.ts | 24 ++++ 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 test/integration/schema-profili.test.ts create mode 100644 test/unit/profili-core.test.ts create mode 100644 test/unit/ruoli.test.ts diff --git a/test/README.md b/test/README.md index 9bba501..6f2cffa 100644 --- a/test/README.md +++ b/test/README.md @@ -20,7 +20,7 @@ non può inquinare gli altri. | 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 | Sì | +| `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 | — | @@ -28,6 +28,13 @@ non può inquinare gli altri. - **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 diff --git a/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts index aea1fbf..f2c2331 100644 --- a/test/e2e/percorsi.test.ts +++ b/test/e2e/percorsi.test.ts @@ -48,6 +48,7 @@ try { ["/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`); 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/unit/profili-core.test.ts b/test/unit/profili-core.test.ts new file mode 100644 index 0000000..6f4e26e --- /dev/null +++ b/test/unit/profili-core.test.ts @@ -0,0 +1,165 @@ +/** 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, + rosaFallback, + slotDi, + slotLiberi, + 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", +); + +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/ruoli.test.ts b/test/unit/ruoli.test.ts new file mode 100644 index 0000000..90d80df --- /dev/null +++ b/test/unit/ruoli.test.ts @@ -0,0 +1,24 @@ +/** Check dei permessi di amministrazione: `bun test/unit/ruoli.test.ts`. */ +import assert from "node:assert/strict"; +import { risolviAdmin } from "@/lib/ruoli"; +import { adminNomi, giocatori } from "@/lib/crapp-data"; + +const referente = giocatori.find((g) => adminNomi.includes(g.nome))!; +const chiunque = giocatori.find((g) => !adminNomi.includes(g.nome))!; + +// Con una sessione attiva decide il database, e basta: la lista di nomi non conta più. +assert.equal(risolviAdmin(true, chiunque.id), true, "il ruolo nel database concede"); +assert.equal( + risolviAdmin(false, referente.id), + false, + "il ruolo nel database nega anche a chi è nella lista dei nomi", +); +assert.equal(risolviAdmin(true, null), true); + +// Senza sessione (null) resta il ponte temporaneo sulla lista di nomi. +assert.equal(risolviAdmin(null, referente.id), true); +assert.equal(risolviAdmin(null, chiunque.id), false); +assert.equal(risolviAdmin(null, null), false, "nessuna identità, nessun permesso"); +assert.equal(risolviAdmin(null, "gXX"), false, "un id sconosciuto non concede nulla"); + +console.log("ruoli: ok"); From 3f52e9cc54d9a4a18d36bc03a13c2cb6d23370cf Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 17:58:24 +0200 Subject: [PATCH 17/26] Documenta dashboard admin, profili e ambiente locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checklist "Fine lavoro" di AGENTS.md per il lavoro dei due commit precedenti. - DATABASE.md: profili_giocatore creata, user_roles come fonte dei permessi, e la nuova sezione Storage per il bucket privato. - ARCHITECTURE.md: autenticazione e ruoli tra i punti fermi, comandi dello stack Supabase locale; gli stessi comandi in CLAUDE.md. - CHANGELOG.md: la voce è marcata come presente su develop e non ancora in produzione. - PROJECT_STATE.md: i cinque passaggi per attivare login e dashboard in produzione, in ordine, con il redirect URI di Google e la query per il primo admin. Segnalato che dev e produzione condividono lo stesso database. - TODO.md: la dashboard esce dal backlog; entra il profilo lato giocatore, senza il quale la dashboard resterebbe senza dati. - modules/profilo-giocatore.md: registrate due scelte fatte in corso d'opera — solo Google al posto di "Google oppure Email", e "Visualizza profilo" come scheda in linea invece di una schermata separata. ROADMAP.md resta con la casella non spuntata: la funzionalità è su develop, non ancora rilasciata. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- CLAUDE.md | 5 +++++ PROJECT_STATE.md | 32 ++++++++++++++++++++++++++++-- docs/ARCHITECTURE.md | 20 ++++++++++++++++++- docs/CHANGELOG.md | 13 ++++++++++++ docs/DATABASE.md | 10 ++++++++-- docs/TODO.md | 8 +++++++- docs/modules/profilo-giocatore.md | 9 ++++++--- supabase/.branches/_current_branch | 1 + 8 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 supabase/.branches/_current_branch diff --git a/CLAUDE.md b/CLAUDE.md index 4157f10..1395b1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,11 @@ 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 index 001e077..d6c988d 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -6,7 +6,9 @@ Ultimo aggiornamento: 30/08/2026 Fase corrente: -Backend migrato al nuovo Supabase proprietario. M1 completata. Profilo Giocatore da implementare (M2). +Backend migrato al nuovo Supabase proprietario. M1 completata. M2 e M3 scritte e da applicare. +Autenticazione Google e dashboard amministratore implementate su `develop`, da attivare in +produzione seguendo i passaggi più sotto. Profilo Giocatore lato giocatore ancora da fare. --- @@ -56,9 +58,35 @@ Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`) --- +## Autenticazione e dashboard amministratore + +Implementate su `develop`, **non ancora attive in produzione**. Il codice è additivo: finché +i passaggi qui sotto non sono fatti, l'app si comporta esattamente come prima. + +Passaggi in ordine, nessuno dei quali è reversibile a metà: + +1. **Provider Google in Supabase** — Google Cloud Console: consent screen *External* (scope + `email` e `profile`, nessuna verifica richiesta), 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. +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 = '<mail>';` +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**: `VITE_AUTH_OBBLIGATORIA=true` su Vercel (fa sparire la + selezione libera del giocatore), poi la migration che rimuove le policy `anon` dalle + tabelle v1.0. È l'unico passo che cambia il comportamento per tutti. + +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 -**M2** — `profili_giocatore` + Supabase Storage privato + RLS +Profilo giocatore lato giocatore (caricamento di documento, certificato e foto tessera): +finché non esiste, la dashboard amministratore mostra profili vuoti. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8bcc39..7107d3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,7 +44,12 @@ docs/ documentazione ufficiale 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. + 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); i permessi + di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`). La variabile + `VITE_AUTH_OBBLIGATORIA` decide se `/benvenuto` accetta ancora la selezione libera del + giocatore: finché è spenta, login e vecchio accesso convivono. ## Livello dati @@ -95,6 +100,19 @@ 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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 196999b..1e44b2c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,19 @@ 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). +- I permessi di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`) e non più + dalla lista di nomi in `crapp-data.ts`, che resta solo come ponte finché + `VITE_AUTH_OBBLIGATORIA` non viene acceso. +- 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. + ### Test - Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun, diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 0cd85bb..849b099 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -11,11 +11,17 @@ non in questo file. |---|---|---| | `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`. | **Prevista** per la v1.1 (DD-016), non ancora creata. | -| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | | +| `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 (`<giocatore_id>/<sezione>.<est>`). | **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 | diff --git a/docs/TODO.md b/docs/TODO.md index e1967ba..b975d7c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,9 +6,16 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ ## In corso - Documentazione tecnica del progetto. +- Autenticazione Google e dashboard amministratore: implementate su `develop`. Restano da + fare, in quest'ordine: configurazione del provider Google in Supabase, applicazione delle + migration M2/M3, inserimento del primo admin in `user_roles`, collegamento dei 17 account, + e solo alla fine `VITE_AUTH_OBBLIGATORIA=true` + rimozione delle policy `anon`. + Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md). ## Prossimo +- Profilo giocatore lato giocatore: senza le schermate di caricamento di documento, + certificato e foto, la dashboard amministratore resta a zero dati. - Certificati medici (roadmap v1.1). - Gestione tesseramenti CSI (roadmap v1.1). @@ -31,5 +38,4 @@ Non documentate nemmeno le route API pubbliche in `src/routes/api/public/` (`csi ## Backlog - AI Allenamenti (roadmap v1.2). -- Dashboard amministratore (roadmap v1.1). - Backup automatici (roadmap, idee future). diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index ed4f0c9..9c99f2b 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -31,7 +31,9 @@ Può: ### Primo accesso -1. Login tramite Google oppure Email. +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. @@ -134,7 +136,8 @@ Contiene. ## Dashboard amministratore -Gli amministratori dispongono di una schermata dedicata. +Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da +Profilo → Impostazioni). Per ogni giocatore vengono mostrati. @@ -145,7 +148,7 @@ Per ogni giocatore vengono mostrati. Azioni disponibili. -- Visualizza profilo +- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata) - Scarica certificato - Scarica documento - Scarica foto tessera 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 From 718ef09dfadae8a2eef5bbad350ce0e42667a405 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 18:15:16 +0200 Subject: [PATCH 18/26] =?UTF-8?q?L'amministratore=20pu=C3=B2=20modificare?= =?UTF-8?q?=20i=20dati=20dei=20giocatori?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attua DD-017. La scheda della dashboard era di sola lettura: ora l'admin apre il giocatore e modifica. - Dati squadra (nome, cognome, numero, ruolo): le docs li assegnavano già agli amministratori, ma non esisteva nessuna schermata per cambiarli. - Dati personali e del documento: compilabili al posto del giocatore, perché un export CSI incompleto rimanda il lavoro in chat. - Scollega account: libera uno slot assegnato per errore, come previsto da DD-016 regola 2. I file restano fuori: l'admin li scarica ma non li carica al posto di altri. Nessuna migration: le policy di M1 e M2 riconoscevano già l'admin. I campi del profilo diventano un componente condiviso (CampiProfilo) tra la schermata del giocatore e la dashboard, con gli upload passati come slot: da admin quelle righe non compaiono. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../crapp/ProfiloAmministrativo.tsx | 349 ++++++++++-------- src/lib/giocatori-squadra.ts | 74 ++++ src/routes/admin.tsx | 223 ++++++++--- 3 files changed, 453 insertions(+), 193 deletions(-) diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx index 1c8ad91..ee88faf 100644 --- a/src/components/crapp/ProfiloAmministrativo.tsx +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -12,10 +12,18 @@ import { useSalvaProfilo, type SezioneFile, } from "@/lib/profili"; -import { completamento, profiloVuoto, sezioniComplete, type Profilo } from "@/lib/profili-core"; +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 ( <label className="block"> @@ -27,7 +35,14 @@ function Campo({ label, children }: { label: string; children: React.ReactNode } ); } -const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; +function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { + return ( + <div className="flex items-center gap-2 pt-2"> + <h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3> + {completa ? <Check className="h-4 w-4 text-success" /> : null} + </div> + ); +} function CampoFile({ label, @@ -105,6 +120,144 @@ function CampoFile({ ); } +/** + * 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<Profilo>) => void; + sezioni: Record<Sezione, boolean>; + fileDocumento?: React.ReactNode; + fileCertificato?: React.ReactNode; + fileFoto?: React.ReactNode; +}) { + return ( + <> + <Intestazione titolo="Dati personali" completa={sezioni.dati} /> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Data di nascita"> + <input + type="date" + value={corrente.dataNascita ?? ""} + onChange={(e) => aggiorna({ dataNascita: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Luogo di nascita"> + <input + value={corrente.luogoNascita ?? ""} + maxLength={80} + onChange={(e) => aggiorna({ luogoNascita: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + <Campo label="Indirizzo di residenza"> + <input + value={corrente.indirizzo ?? ""} + maxLength={120} + onChange={(e) => aggiorna({ indirizzo: e.target.value })} + className={classiInput} + /> + </Campo> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Telefono"> + <input + type="tel" + value={corrente.telefono ?? ""} + maxLength={20} + onChange={(e) => aggiorna({ telefono: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Email"> + <input + type="email" + value={corrente.email ?? ""} + maxLength={120} + onChange={(e) => aggiorna({ email: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + + <Intestazione titolo="Documento di identità" completa={sezioni.documento} /> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Tipo"> + <select + value={corrente.documentoTipo ?? ""} + onChange={(e) => aggiorna({ documentoTipo: e.target.value })} + className={classiInput} + > + <option value="">—</option> + {TIPI_DOCUMENTO.map((t) => ( + <option key={t} value={t}> + {t} + </option> + ))} + </select> + </Campo> + <Campo label="Numero"> + <input + value={corrente.documentoNumero ?? ""} + maxLength={40} + onChange={(e) => aggiorna({ documentoNumero: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + <Campo label="Rilasciato da"> + <input + value={corrente.documentoRilasciatoDa ?? ""} + maxLength={80} + onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })} + className={classiInput} + /> + </Campo> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Data emissione"> + <input + type="date" + value={corrente.documentoEmissione ?? ""} + onChange={(e) => aggiorna({ documentoEmissione: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Data scadenza"> + <input + type="date" + value={corrente.documentoScadenza ?? ""} + onChange={(e) => aggiorna({ documentoScadenza: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + {fileDocumento} + + <Intestazione titolo="Certificato medico" completa={sezioni.certificato} /> + <Campo label="Data di scadenza"> + <input + type="date" + value={corrente.certificatoScadenza ?? ""} + onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })} + className={classiInput} + /> + </Campo> + {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. @@ -152,7 +305,7 @@ export function ProfiloAmministrativo({ <Section titolo="Dati per il tesseramento" indice={indice} - azione={<span className="text-xs font-bold text-muted-foreground tabular-nums">{perc}%</span>} + azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>} > <div className="space-y-3 rounded-3xl bg-card p-4 shadow-card"> <div className="h-1.5 overflow-hidden rounded-full bg-secondary"> @@ -166,146 +319,49 @@ export function ProfiloAmministrativo({ Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. </p> - <Intestazione titolo="Dati personali" completa={sezioni.dati} /> - <div className="grid grid-cols-2 gap-3"> - <Campo label="Data di nascita"> - <input - type="date" - value={corrente.dataNascita ?? ""} - onChange={(e) => aggiorna({ dataNascita: e.target.value })} - className={classiInput} + <CampiProfilo + corrente={corrente} + aggiorna={aggiorna} + sezioni={sezioni} + fileDocumento={ + <div className="divide-y divide-border"> + <CampoFile + label="Foto fronte" + path={corrente.documentoFrontePath} + sezione="documento-fronte" + giocatoreId={giocatoreId} + onCaricato={caricato("documentoFrontePath")} + /> + <CampoFile + label="Foto retro" + path={corrente.documentoRetroPath} + sezione="documento-retro" + giocatoreId={giocatoreId} + onCaricato={caricato("documentoRetroPath")} + /> + </div> + } + fileCertificato={ + <CampoFile + label="Certificato medico" + path={corrente.certificatoPath} + sezione="certificato" + giocatoreId={giocatoreId} + onCaricato={caricato("certificatoPath")} /> - </Campo> - <Campo label="Luogo di nascita"> - <input - value={corrente.luogoNascita ?? ""} - maxLength={80} - onChange={(e) => aggiorna({ luogoNascita: e.target.value })} - className={classiInput} - /> - </Campo> - </div> - <Campo label="Indirizzo di residenza"> - <input - value={corrente.indirizzo ?? ""} - maxLength={120} - onChange={(e) => aggiorna({ indirizzo: e.target.value })} - className={classiInput} - /> - </Campo> - <div className="grid grid-cols-2 gap-3"> - <Campo label="Telefono"> - <input - type="tel" - value={corrente.telefono ?? ""} - maxLength={20} - onChange={(e) => aggiorna({ telefono: e.target.value })} - className={classiInput} - /> - </Campo> - <Campo label="Email"> - <input - type="email" - value={corrente.email ?? ""} - maxLength={120} - onChange={(e) => aggiorna({ email: e.target.value })} - className={classiInput} - /> - </Campo> - </div> - - <Intestazione titolo="Documento di identità" completa={sezioni.documento} /> - <div className="grid grid-cols-2 gap-3"> - <Campo label="Tipo"> - <select - value={corrente.documentoTipo ?? ""} - onChange={(e) => aggiorna({ documentoTipo: e.target.value })} - className={classiInput} - > - <option value="">—</option> - {TIPI_DOCUMENTO.map((t) => ( - <option key={t} value={t}> - {t} - </option> - ))} - </select> - </Campo> - <Campo label="Numero"> - <input - value={corrente.documentoNumero ?? ""} - maxLength={40} - onChange={(e) => aggiorna({ documentoNumero: e.target.value })} - className={classiInput} - /> - </Campo> - </div> - <Campo label="Rilasciato da"> - <input - value={corrente.documentoRilasciatoDa ?? ""} - maxLength={80} - onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })} - className={classiInput} - /> - </Campo> - <div className="grid grid-cols-2 gap-3"> - <Campo label="Data emissione"> - <input - type="date" - value={corrente.documentoEmissione ?? ""} - onChange={(e) => aggiorna({ documentoEmissione: e.target.value })} - className={classiInput} - /> - </Campo> - <Campo label="Data scadenza"> - <input - type="date" - value={corrente.documentoScadenza ?? ""} - onChange={(e) => aggiorna({ documentoScadenza: e.target.value })} - className={classiInput} - /> - </Campo> - </div> - <div className="divide-y divide-border"> - <CampoFile - label="Foto fronte" - path={corrente.documentoFrontePath} - sezione="documento-fronte" - giocatoreId={giocatoreId} - onCaricato={caricato("documentoFrontePath")} - /> - <CampoFile - label="Foto retro" - path={corrente.documentoRetroPath} - sezione="documento-retro" - giocatoreId={giocatoreId} - onCaricato={caricato("documentoRetroPath")} - /> - </div> - - <Intestazione titolo="Certificato medico" completa={sezioni.certificato} /> - <Campo label="Data di scadenza"> - <input - type="date" - value={corrente.certificatoScadenza ?? ""} - onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })} - className={classiInput} - /> - </Campo> - <CampoFile - label="Certificato medico" - path={corrente.certificatoPath} - sezione="certificato" - giocatoreId={giocatoreId} - onCaricato={caricato("certificatoPath")} - /> - - <Intestazione titolo="Foto tessera" completa={sezioni.foto} /> - <CampoFile - label="Foto tessera" - path={corrente.fotoPath} - sezione="foto" - giocatoreId={giocatoreId} - onCaricato={caricato("fotoPath")} + } + fileFoto={ + <> + <Intestazione titolo="Foto tessera" completa={sezioni.foto} /> + <CampoFile + label="Foto tessera" + path={corrente.fotoPath} + sezione="foto" + giocatoreId={giocatoreId} + onCaricato={caricato("fotoPath")} + /> + </> + } /> <button @@ -359,12 +415,3 @@ export function CompletaProfilo({ </Reveal> ); } - -function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { - return ( - <div className="flex items-center gap-2 pt-2"> - <h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3> - {completa ? <Check className="h-4 w-4 text-success" /> : null} - </div> - ); -} diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts index ca16cc3..21d53f2 100644 --- a/src/lib/giocatori-squadra.ts +++ b/src/lib/giocatori-squadra.ts @@ -90,6 +90,80 @@ export function useGiocatoriSquadra() { return { ...query, righe, daDatabase: !!query.data?.length }; } +/** Dati squadra: li gestisce solo un amministratore (DD-017). */ +export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo">; + +/** + * Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori): + * meglio dirlo qui che far tornare un errore Postgres all'utente. + * Restituisce il messaggio da mostrare, oppure `null` se va bene. + */ +export function validaDatiSquadra(dati: DatiSquadra): string | null { + if (!dati.nome.trim()) return "Il nome non può essere vuoto."; + if (!dati.cognome.trim()) return "Il cognome non può essere vuoto."; + if (!Number.isInteger(dati.numero) || dati.numero <= 0) + return "Il numero di maglia deve essere maggiore di zero."; + if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto."; + return null; +} + +/** Numeri di maglia doppi: il database li accetta, la squadra no. */ +export function numeroGiaUsato( + righe: GiocatoreSquadra[], + giocatoreId: string, + numero: number, +): boolean { + return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero); +} + +/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */ +export function useSalvaDatiSquadra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => { + const { error } = await supabaseNuoveTabelle + .from("giocatori_squadra") + .update({ + nome: input.dati.nome.trim(), + cognome: input.dati.cognome.trim(), + numero: input.dati.numero, + ruolo: input.dati.ruolo.trim(), + }) + .eq("id", input.giocatoreId); + if (error) throw error; + return input; + }, + onSuccess: (input) => { + queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) => + (prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)), + ); + }, + }); +} + +/** + * Libera uno slot occupato per errore (DD-016 regola 2, DD-017). Il giocatore + * potrà ricollegarsi al primo accesso; i dati del profilo restano dove sono. + */ +export function useScollegaAccount() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (giocatoreId: string) => { + const { error } = await supabaseNuoveTabelle + .from("giocatori_squadra") + .update({ auth_user_id: null }) + .eq("id", giocatoreId); + if (error) throw error; + return giocatoreId; + }, + onSuccess: (giocatoreId) => { + queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) => + (prec ?? []).map((g) => (g.id === giocatoreId ? { ...g, authUserId: null } : g)), + ); + }, + }); +} + /** * Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se * lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 7511cdc..8326c2e 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,14 +1,34 @@ import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { ChevronDown, Download, FileText, IdCard, Image, Loader2, Lock } from "lucide-react"; +import { + ChevronDown, + Download, + FileText, + IdCard, + Image, + Loader2, + Lock, + Unlink, +} from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; -import { useGiocatoriSquadra, nomeCompleto } from "@/lib/giocatori-squadra"; -import { useProfili, scaricaFile } from "@/lib/profili"; +import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo"; +import { + nomeCompleto, + numeroGiaUsato, + useGiocatoriSquadra, + useSalvaDatiSquadra, + useScollegaAccount, + validaDatiSquadra, + type DatiSquadra, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; +import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili"; import { completamento, csvTesseramento, + profiloVuoto, sezioniComplete, statoScadenza, type Profilo, @@ -46,11 +66,159 @@ const statoClasse: Record<StatoScadenza | "presente" | "assente", string> = { assente: "bg-secondary text-muted-foreground", }; -function Riga({ etichetta, valore }: { etichetta: string; valore: string | null }) { +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 ( - <div className="flex items-baseline justify-between gap-3 py-1 text-sm"> - <span className="shrink-0 text-muted-foreground">{etichetta}</span> - <span className="truncate text-right font-medium">{valore || "—"}</span> + <label className="block"> + <span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground"> + {label} + </span> + <span className="mt-1 block">{children}</span> + </label> + ); +} + +/** + * Pannello di modifica dell'admin (DD-017): dati squadra, dati personali e collegamento + * all'account. I file restano fuori: l'admin li scarica, non li carica al posto di altri. + */ +function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profilo | undefined }) { + const { righe } = useGiocatoriSquadra(); + const salvaSquadra = useSalvaDatiSquadra(); + const salvaProfilo = useSalvaProfilo(); + const scollega = useScollegaAccount(); + + const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null); + const [bozza, setBozza] = useState<Profilo | null>(null); + + const squadraCorrente: DatiSquadra = datiSquadra ?? { + nome: g.nome, + cognome: g.cognome, + numero: g.numero, + ruolo: g.ruolo, + }; + const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id); + + async function confermaSquadra() { + const errore = validaDatiSquadra(squadraCorrente); + if (errore) { + toast.error(errore); + return; + } + if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) { + toast.warning(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`); + } + try { + await salvaSquadra.mutateAsync({ giocatoreId: g.id, dati: squadraCorrente }); + setDatiSquadra(null); + toast.success("Dati squadra aggiornati"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito"); + } + } + + async function confermaProfilo() { + try { + await salvaProfilo.mutateAsync(profiloCorrente); + setBozza(null); + toast.success("Profilo aggiornato"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito"); + } + } + + async function confermaScollega() { + if ( + !confirm( + `Scollegare l'account di ${nomeCompleto(g)}? Potrà ricollegarsi al prossimo accesso.`, + ) + ) + return; + try { + await scollega.mutateAsync(g.id); + toast.success("Account scollegato"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Operazione non riuscita"); + } + } + + return ( + <div className="mt-3 space-y-3 border-t border-border pt-3"> + <h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3> + <div className="grid grid-cols-2 gap-3"> + <Campo label="Nome"> + <input + value={squadraCorrente.nome} + maxLength={40} + onChange={(e) => setDatiSquadra({ ...squadraCorrente, nome: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Cognome"> + <input + value={squadraCorrente.cognome} + maxLength={40} + onChange={(e) => setDatiSquadra({ ...squadraCorrente, cognome: e.target.value })} + className={classiInput} + /> + </Campo> + <Campo label="Numero"> + <input + type="number" + min={1} + value={squadraCorrente.numero} + onChange={(e) => setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })} + className={classiInput} + /> + </Campo> + <Campo label="Ruolo"> + <input + value={squadraCorrente.ruolo} + maxLength={30} + onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })} + className={classiInput} + /> + </Campo> + </div> + <button + type="button" + onClick={confermaSquadra} + disabled={!datiSquadra || salvaSquadra.isPending} + className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50" + > + Salva dati squadra + </button> + + <CampiProfilo + corrente={profiloCorrente} + aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })} + sezioni={sezioniComplete(profiloCorrente)} + /> + <button + type="button" + onClick={confermaProfilo} + disabled={!bozza || salvaProfilo.isPending} + className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50" + > + Salva dati personali + </button> + + <div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs"> + <span className="min-w-0 text-muted-foreground"> + {g.authUserId ? "Account collegato" : "Nessun account collegato"} + </span> + {g.authUserId ? ( + <button + type="button" + onClick={confermaScollega} + disabled={scollega.isPending} + className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-destructive px-3 py-2 font-bold text-destructive-foreground disabled:opacity-50" + > + <Unlink className="h-3.5 w-3.5" /> Scollega + </button> + ) : null} + </div> </div> ); } @@ -98,21 +266,19 @@ function Documento({ } function SchedaGiocatore({ - nome, - ruolo, - numero, + g, profilo, oggi, indice, }: { - nome: string; - ruolo: string; - numero: number; + 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); @@ -177,26 +343,7 @@ function SchedaGiocatore({ /> </div> - {aperta ? ( - <div className="mt-3 border-t border-border pt-3"> - <Riga etichetta="Data di nascita" valore={profilo?.dataNascita ?? null} /> - <Riga etichetta="Luogo di nascita" valore={profilo?.luogoNascita ?? null} /> - <Riga etichetta="Indirizzo" valore={profilo?.indirizzo ?? null} /> - <Riga etichetta="Telefono" valore={profilo?.telefono ?? null} /> - <Riga etichetta="Email" valore={profilo?.email ?? null} /> - <Riga - etichetta="Documento" - valore={ - profilo?.documentoNumero - ? `${profilo.documentoTipo ?? ""} ${profilo.documentoNumero}`.trim() - : null - } - /> - <Riga etichetta="Rilasciato da" valore={profilo?.documentoRilasciatoDa ?? null} /> - <Riga etichetta="Scadenza documento" valore={profilo?.documentoScadenza ?? null} /> - <Riga etichetta="Scadenza certificato" valore={profilo?.certificatoScadenza ?? null} /> - </div> - ) : null} + {aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null} </Reveal> ); } @@ -262,15 +409,7 @@ function Dashboard() { ) : ( <div className="space-y-3"> {attivi.map((g, i) => ( - <SchedaGiocatore - key={g.id} - nome={nomeCompleto(g)} - ruolo={g.ruolo} - numero={g.numero} - profilo={profili[g.id]} - oggi={oggi} - indice={i} - /> + <SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} /> ))} </div> )} From 53b22529973fc21c7d5468c9c59e8cd880f254d5 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 18:15:16 +0200 Subject: [PATCH 19/26] Test sulla validazione dei dati squadra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copre i controlli che evitano un errore Postgres di ritorno: nome, cognome e ruolo non vuoti, numero di maglia intero e positivo (il CHECK della tabella), e il rilevamento di un numero già assegnato a un altro giocatore attivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- test/unit/profili-core.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts index 6f4e26e..d18f209 100644 --- a/test/unit/profili-core.test.ts +++ b/test/unit/profili-core.test.ts @@ -11,9 +11,11 @@ import { } from "@/lib/profili-core"; import { dividiNome, + numeroGiaUsato, rosaFallback, slotDi, slotLiberi, + validaDatiSquadra, type GiocatoreSquadra, } from "@/lib/giocatori-squadra"; @@ -155,6 +157,29 @@ assert.deepEqual( "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( From 9e17c2faddfd88dd0beb5cdfe65bde1972eb2109 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 18:15:16 +0200 Subject: [PATCH 20/26] DD-017: l'admin scrive al posto del giocatore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registra la decisione prima del codice, come chiede AGENTS.md. Il modello dei permessi passa da "ognuno i suoi" a "ognuno i suoi, più l'admin su tutti, tranne i file": senza, l'export per il tesseramento resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto. Gli upload restano al giocatore perché su documenti d'identità e dati sanitari la catena di responsabilità deve restare leggibile. Aggiornati di conseguenza il documento di modulo (utenti, azioni della dashboard, permessi) e il changelog. Annotato nel riesame che oggi non esiste audit di chi modifica un dato. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/CHANGELOG.md | 4 ++++ docs/DESIGN_DECISIONS.md | 38 +++++++++++++++++++++++++++++++ docs/modules/profilo-giocatore.md | 12 +++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1e44b2c..6c7c487 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,10 @@ qui: sta in [ROADMAP.md](ROADMAP.md). 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 diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 384643d..ce954cf 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -32,6 +32,7 @@ Serve a rispondere a domande del tipo: | [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** @@ -436,6 +437,43 @@ Regole vincolanti: --- +### 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 --- diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 9c99f2b..43fab85 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -26,6 +26,12 @@ Può: - 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 @@ -152,6 +158,8 @@ Azioni disponibili. - 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 @@ -189,7 +197,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%. **Giocatore** — può modificare esclusivamente il proprio profilo. -**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti ed esportare i dati. +**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 From 764f75085edd80425f45458c0fa55d452a97c7ea Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 22:47:55 +0200 Subject: [PATCH 21/26] Sync roadmap and project state with the code Mark medical certificates, admin dashboard and CSV export as done: the player-side profile screens exist, so the notes calling them missing were stale. Leave CSI membership and official calendar open, each with what is already there. Record the current dev state of Google auth: implemented but returning "provider is not enabled" until the provider is turned on in Supabase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- PROJECT_STATE.md | 36 ++++++++++++++++++++++++------------ docs/CHANGELOG.md | 3 +++ docs/ROADMAP.md | 16 +++++++++++----- docs/TODO.md | 14 +++++++++----- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d6c988d..69855eb 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -7,8 +7,8 @@ Ultimo aggiornamento: 30/08/2026 Fase corrente: Backend migrato al nuovo Supabase proprietario. M1 completata. M2 e M3 scritte e da applicare. -Autenticazione Google e dashboard amministratore implementate su `develop`, da attivare in -produzione seguendo i passaggi più sotto. Profilo Giocatore lato giocatore ancora da fare. +Autenticazione Google, dashboard amministratore e Profilo Giocatore (lato giocatore e lato +admin) implementati su `develop`, da attivare in produzione seguendo i passaggi più sotto. --- @@ -49,12 +49,7 @@ produzione seguendo i passaggi più sotto. Profilo Giocatore lato giocatore anco - Pagelle - MVP - Notifiche - ---- - -## Modulo da implementare - -Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`) +- Profilo Giocatore (su `develop`, specifica in `docs/modules/profilo-giocatore.md`) --- @@ -63,13 +58,30 @@ Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`) Implementate su `develop`, **non ancora attive in produzione**. Il codice è additivo: finché i passaggi qui sotto non sono fatti, l'app si comporta esattamente come prima. +**Stato in sviluppo (30/08/2026):** il codice c'è ed è completo, ma il login non funziona +ancora. Con `npm run dev`, «Accedi con Google» risponde: + +``` +{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"} +``` + +Non è un difetto dell'app: l'errore arriva da Supabase, dove il provider Google è spento. +È il passo 1 qui sotto, ancora da fare. Tutto il resto dell'app in dev funziona normalmente, +perché l'accesso avviene ancora scegliendo il proprio nome. + Passaggi in ordine, nessuno dei quali è reversibile a metà: 1. **Provider Google in Supabase** — Google Cloud Console: consent screen *External* (scope - `email` e `profile`, nessuna verifica richiesta), credenziale *Web application* con - redirect URI `https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e + `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): @@ -85,8 +97,8 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin. ## Prossimo sviluppo -Profilo giocatore lato giocatore (caricamento di documento, certificato e foto tessera): -finché non esiste, la dashboard amministratore mostra profili vuoti. +Gestione tesseramenti CSI: la raccolta dati e l'export CSV sono pronti, manca il +tracciamento di chi è già tesserato (numero e data di tessera). --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6c7c487..d1311de 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,9 @@ qui: sta in [ROADMAP.md](ROADMAP.md). - I permessi di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`) e non più dalla lista di nomi in `crapp-data.ts`, che resta solo come ponte finché `VITE_AUTH_OBBLIGATORIA` non viene acceso. +- 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8559122..230a602 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,10 +19,15 @@ sta facendo adesso. ## Versione 1.1 -- [ ] Certificati medici -- [ ] Gestione tesseramenti CSI -- [ ] Dashboard amministratore -- [ ] Download CSV dati +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 @@ -35,7 +40,8 @@ sta facendo adesso. - [x] Collegamento CSI (stagione 2025/26) - [x] Classifica automatica - [x] Risultati campionato -- [ ] Calendario ufficiale +- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina + Campionato usa solo quelle giocate ## Idee future diff --git a/docs/TODO.md b/docs/TODO.md index b975d7c..450677d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,7 +6,9 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ ## In corso - Documentazione tecnica del progetto. -- Autenticazione Google e dashboard amministratore: implementate su `develop`. Restano da +- Autenticazione Google e dashboard amministratore: implementate su `develop`, ma in dev il + login risponde ancora `provider is not enabled` perché il provider Google non è stato + acceso in Supabase. Restano da fare, in quest'ordine: configurazione del provider Google in Supabase, applicazione delle migration M2/M3, inserimento del primo admin in `user_roles`, collegamento dei 17 account, e solo alla fine `VITE_AUTH_OBBLIGATORIA=true` + rimozione delle policy `anon`. @@ -14,10 +16,12 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ ## Prossimo -- Profilo giocatore lato giocatore: senza le schermate di caricamento di documento, - certificato e foto, la dashboard amministratore resta a zero dati. -- Certificati medici (roadmap v1.1). -- Gestione tesseramenti CSI (roadmap v1.1). +- 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 From 990c2bbd4c9bee37c687e30056de06c211286467 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Sun, 30 Aug 2026 22:47:55 +0200 Subject: [PATCH 22/26] Add commit conventions for AI assistants Commit only when asked, message written by the assistant in English. The only exception to the Italian-everywhere rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9a774ef..cdb4c5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,20 @@ idea → progettazione → documento in docs/modules/ → database → implement modifica deve mantenere l'app perfettamente funzionante. - `develop` = sviluppo e preview; tutte le nuove implementazioni nascono qui. +### Commit + +- **Si committa solo quando l'utente lo chiede**, mai di propria iniziativa. Lo stesso vale + per il push, che su `develop` fa partire un deploy di preview. +- Quando l'utente lo chiede, **il messaggio lo scrive l'assistente in autonomia**, senza + farlo approvare prima. +- **Il messaggio è in inglese**, all'imperativo presente (`Add medical certificate expiry`), + riga di riepilogo sotto i 72 caratteri. È l'unica eccezione all'italiano: codice, commenti + e documentazione restano in italiano. I commit precedenti sono in italiano e non vanno + riscritti. +- Se il lavoro attua una decisione registrata, il messaggio la cita: `DD-017: ...`. +- Nel commit entrano insieme codice e documentazione: la checklist + [Fine lavoro](#fine-lavoro-cosa-aggiornare-sempre) va eseguita prima, non in un commit a parte. + ## Vincoli tecnici da non violare - `src/routeTree.gen.ts` e `src/integrations/supabase/{client.ts,types.ts}` sono **generati**: From f424b3a9f7a773838605644a9f7959e6429d486d Mon Sep 17 00:00:00 2001 From: Ivan Cacciari <ivan.cacciari.1995@gmail.com> Date: Mon, 31 Aug 2026 15:25:07 +0200 Subject: [PATCH 23/26] Add M2 migration for player profiles and private storage. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../20260830123000_m2_profili_giocatore.sql | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 supabase/migrations/20260830123000_m2_profili_giocatore.sql 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) + ) + ); From e4f963d1708c554b5a26db78afe17debcba7bcf1 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari <ivan.cacciari.1995@gmail.com> Date: Mon, 31 Aug 2026 15:31:10 +0200 Subject: [PATCH 24/26] Update AI and collaboration workflow --- AGENTS.md | 519 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 414 insertions(+), 105 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdb4c5a..45a6116 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,141 +1,450 @@ -# AGENTS.md — CrAPP +# CrAPP - AI Development Guide -Regole che qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT o altri) deve seguire -quando lavora su questo progetto. **È l'unica fonte delle regole**: `CLAUDE.md` e -`.cursor/rules/` rimandano qui, non ripetono nulla. +Questo documento definisce le regole che qualsiasi assistente AI (Cursor, Claude Code, Codex, ChatGPT o altri) deve seguire quando lavora su questo progetto. -> **Qualsiasi cosa venga aggiunta o modificata — una regola, una funzionalità, una decisione, -> una tabella — va registrata nei file di riferimento del progetto prima di considerare il -> lavoro finito**, indipendentemente dall'assistente con cui è stata fatta. Vedi -> [Fine lavoro](#fine-lavoro-cosa-aggiornare-sempre): non è un passaggio opzionale. +--- -CrAPP è una Progressive Web App per la gestione di una squadra di pallavolo amatoriale (CRAP -Volley). Obiettivi e principi in [docs/VISION.md](docs/VISION.md), architettura in -[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +# Obiettivo del progetto -**Codice, commenti, nomi di variabili e documentazione sono in italiano**: mantieni questa -convenzione. +CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo. -## Prima di modificare il codice +L'obiettivo principale è: -Leggere sempre, nell'ordine: +- ridurre il lavoro amministrativo degli amministratori; -1. [docs/README.md](docs/README.md) — indice della documentazione -2. [docs/VISION.md](docs/VISION.md) -3. [docs/ROADMAP.md](docs/ROADMAP.md) -4. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) -5. [docs/DATABASE.md](docs/DATABASE.md) -6. [docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) -7. [docs/TODO.md](docs/TODO.md) -8. il documento del modulo interessato in [docs/modules/](docs/modules/) +- aumentare il coinvolgimento dei giocatori; -**Non implementare funzionalità non documentate** (DD-002). +- centralizzare tutte le informazioni della squadra; -## Workflow +- utilizzare l'intelligenza artificiale solo quando porta un reale beneficio. -``` -idea → progettazione → documento in docs/modules/ → database → implementazione su develop -→ test → merge su main → deploy automatico Vercel -``` +--- -- `main` = produzione, sempre funzionante: **non si modifica direttamente**. Qualsiasi - modifica deve mantenere l'app perfettamente funzionante. -- `develop` = sviluppo e preview; tutte le nuove implementazioni nascono qui. +# Prima di modificare il codice -### Commit +Prima di implementare qualsiasi modifica leggere sempre: -- **Si committa solo quando l'utente lo chiede**, mai di propria iniziativa. Lo stesso vale - per il push, che su `develop` fa partire un deploy di preview. -- Quando l'utente lo chiede, **il messaggio lo scrive l'assistente in autonomia**, senza - farlo approvare prima. -- **Il messaggio è in inglese**, all'imperativo presente (`Add medical certificate expiry`), - riga di riepilogo sotto i 72 caratteri. È l'unica eccezione all'italiano: codice, commenti - e documentazione restano in italiano. I commit precedenti sono in italiano e non vanno - riscritti. -- Se il lavoro attua una decisione registrata, il messaggio la cita: `DD-017: ...`. -- Nel commit entrano insieme codice e documentazione: la checklist - [Fine lavoro](#fine-lavoro-cosa-aggiornare-sempre) va eseguita prima, non in un commit a parte. +1. docs/[README.md](http://README.md) -## Vincoli tecnici da non violare +2. docs/[VISION.md](http://VISION.md) -- `src/routeTree.gen.ts` e `src/integrations/supabase/{client.ts,types.ts}` sono **generati**: - non modificarli a mano. -- **Non ri-aggiungere** i plugin Vite (devtools, tanstackStart, viteReact, tailwind, - tsconfig-paths, nitro) già inclusi da `@lovable.dev/vite-tanstack-config` in - `vite.config.ts`: l'app si rompe. -- Nessun accesso al database dai componenti: solo tramite i moduli in `src/lib/`, così il - backend resta sostituibile in un solo punto (DD-013). -- Efficienza cloud: niente polling, cache lunga, e dopo una mutazione `setQueryData` invece di - `invalidateQueries`. Regole complete in [docs/EFFICIENZA_CLOUD.md](docs/EFFICIENZA_CLOUD.md). -- Badge e statistiche si calcolano a runtime, non si persistono (DD-007); la gamification - resta equa tra ruoli (DD-008): niente metriche che favoriscano attaccanti o liberi. -- L'app deve poter girare su Node.js + PostgreSQL standard: niente servizi esclusivi - Lovable/Vercel (DD-001, DD-013, [docs/PORTABILITA.md](docs/PORTABILITA.md)). +3. docs/[ROADMAP.md](http://ROADMAP.md) -## Database +4. docs/[ARCHITECTURE.md](http://ARCHITECTURE.md) -- Non eliminare tabelle esistenti. -- Non modificare lo schema senza creare una migration in `supabase/migrations/`. -- Preferire una nuova tabella all'aggiunta di molte colonne, quando il modulo è indipendente. -- Preferire strutture scalabili; evitare duplicazione dei dati. -- Riferimento: [docs/DATABASE.md](docs/DATABASE.md), da aggiornare nella stessa modifica che - cambia lo schema. +5. docs/[DATABASE.md](http://DATABASE.md) -## Codice e componenti +6. docs/DESIGN_[DECISIONS.md](http://DECISIONS.md) -- TypeScript, funzioni piccole, nomi descrittivi. -- Componenti piccoli, riutilizzabili, a responsabilità singola. -- Nessuna duplicazione: riusare sempre i componenti e i moduli esistenti. -- Commentare solo il codice realmente complesso. -- Nessuna nuova dipendenza senza reale necessità; mantenere la struttura esistente. -- Le dipendenze si installano con **bun**; `bunfig.toml` impone `minimumReleaseAge = 24h` come - guardia supply-chain: aggiungere un pacchetto a `minimumReleaseAgeExcludes` richiede - conferma esplicita dell'utente. +7. docs/[TODO.md](http://TODO.md) -## Interfaccia +8. il documento interessato in docs/modules/ -Stile coerente con l'esistente: semplice, moderna, pulita, veloce, ottimizzata per -smartphone, poche schermate e pochi click (DD-005). Riusare i componenti in -`src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`, `StatTile`) e le primitive -shadcn in `src/components/ui/`. +Inoltre, prima di iniziare una nuova attività: -## Fine lavoro: cosa aggiornare sempre +- verificare lo stato attuale del repository; -Il lavoro **non è finito** finché non è registrato dove va. Vale per tutti gli assistenti allo -stesso modo: chi fa la modifica aggiorna i file, chiunque la stia facendo e da qualunque -strumento. Un cambiamento che vive solo nel codice o solo nella chat è un cambiamento perso. +- controllare le modifiche e i commit recenti; -| Cosa hai aggiunto o cambiato | Dove va registrato | -|---|---| -| Una **regola** per gli assistenti (convenzione, divieto, vincolo di lavoro) | **Questo file, e solo questo.** Mai in `CLAUDE.md` o `.cursor/rules/`: rimandano qui, e una regola scritta lì la vedrebbe un assistente solo | -| Una **funzionalità** | `docs/modules/<modulo>.md` (**prima** di scrivere il codice), poi `docs/ROADMAP.md` e `docs/CHANGELOG.md` | -| Una **decisione** architetturale o di prodotto | `docs/DESIGN_DECISIONS.md`, formato `DD-XXX` (copiare `docs/_template-dd.md`) e aggiungerla all'indice in cima | -| Una **modifica allo schema** del database | una migration in `supabase/migrations/` **e** `docs/DATABASE.md`, nella stessa modifica | -| Lavoro iniziato, sospeso o concluso | `docs/TODO.md` e `PROJECT_STATE.md` | -| Un **comando** o uno script nuovo | `docs/ARCHITECTURE.md` (sezione Comandi) e `CLAUDE.md` | +- verificare eventuali modifiche introdotte da altri sviluppatori o assistenti AI; -Quale informazione vive in quale file — e perché non va duplicata altrove — è spiegato in -[docs/README.md](docs/README.md). +- leggere la documentazione aggiornata relativa alla funzionalità interessata. -## Cosa l'AI non deve fare +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. -## Cosa l'AI deve fare +- 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. -## Filosofia +- riutilizzare i componenti esistenti; -Prima di scrivere codice, chiedersi sempre: +- 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. -- questa modifica rende CrAPP più semplice? -- riduce il lavoro degli amministratori? -- migliora l'esperienza dei giocatori? -- è coerente con la documentazione? From 5a6aaad88526ac5b12903e2136c4e94add35c943 Mon Sep 17 00:00:00 2001 From: Ivan Cacciari <ivan.cacciari.1995@gmail.com> Date: Mon, 31 Aug 2026 17:15:58 +0200 Subject: [PATCH 25/26] Clean up superseded profile migrations --- .../20260830120000_m2_profili_giocatore.sql | 0 .../20260830120100_m3_bucket_profili.sql | 0 ...00_correzione_schema_profili_giocatore.sql | 135 ++++++++++++++++++ 3 files changed, 135 insertions(+) rename {supabase => docs/archive}/migrations/20260830120000_m2_profili_giocatore.sql (100%) rename {supabase => docs/archive}/migrations/20260830120100_m3_bucket_profili.sql (100%) create mode 100644 supabase/migrations/20260831170000_correzione_schema_profili_giocatore.sql diff --git a/supabase/migrations/20260830120000_m2_profili_giocatore.sql b/docs/archive/migrations/20260830120000_m2_profili_giocatore.sql similarity index 100% rename from supabase/migrations/20260830120000_m2_profili_giocatore.sql rename to docs/archive/migrations/20260830120000_m2_profili_giocatore.sql diff --git a/supabase/migrations/20260830120100_m3_bucket_profili.sql b/docs/archive/migrations/20260830120100_m3_bucket_profili.sql similarity index 100% rename from supabase/migrations/20260830120100_m3_bucket_profili.sql rename to docs/archive/migrations/20260830120100_m3_bucket_profili.sql 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'; From 1036eb860d6d92a175e363d52086300197c38360 Mon Sep 17 00:00:00 2001 From: Davide Grilli <davide.grilli@outlook.com> Date: Mon, 31 Aug 2026 18:21:34 +0200 Subject: [PATCH 26/26] DD-011: make Google login the only way in Remove the free player selection from /benvenuto: without a Supabase session no screen renders, and the VITE_AUTH_OBBLIGATORIA bridge flag is gone. Admin rights now come only from user_roles, so the hardcoded name list in crapp-data.ts is deleted along with its tests. Add migration m4_solo_autenticati, which revokes anon access to the v1.0 tables. Apply it only once the whole team has linked an account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- PROJECT_STATE.md | 20 +++++----- docs/ARCHITECTURE.md | 8 ++-- docs/CHANGELOG.md | 12 ++++-- docs/TODO.md | 12 +++--- src/lib/auth.ts | 11 ++---- src/lib/crapp-data.ts | 9 +---- src/lib/ruoli.ts | 16 ++------ src/routes/__root.tsx | 11 ++++-- src/routes/benvenuto.tsx | 37 +++++-------------- src/routes/profilo.tsx | 21 +++-------- .../20260831120000_m4_solo_autenticati.sql | 19 ++++++++++ test/unit/crapp-data.test.ts | 21 +---------- test/unit/ruoli.test.ts | 24 ------------ 13 files changed, 78 insertions(+), 143 deletions(-) create mode 100644 supabase/migrations/20260831120000_m4_solo_autenticati.sql delete mode 100644 test/unit/ruoli.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 69855eb..2344f21 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -55,19 +55,19 @@ admin) implementati su `develop`, da attivare in produzione seguendo i passaggi ## Autenticazione e dashboard amministratore -Implementate su `develop`, **non ancora attive in produzione**. Il codice è additivo: finché -i passaggi qui sotto non sono fatti, l'app si comporta esattamente come prima. +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`. -**Stato in sviluppo (30/08/2026):** il codice c'è ed è completo, ma il login non funziona -ancora. Con `npm run dev`, «Accedi con Google» risponde: +**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"} ``` -Non è un difetto dell'app: l'errore arriva da Supabase, dove il provider Google è spento. -È il passo 1 qui sotto, ancora da fare. Tutto il resto dell'app in dev funziona normalmente, -perché l'accesso avviene ancora scegliendo il proprio nome. +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à: @@ -88,9 +88,9 @@ Passaggi in ordine, nessuno dei quali è reversibile a metà: `INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';` 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**: `VITE_AUTH_OBBLIGATORIA=true` su Vercel (fa sparire la - selezione libera del giocatore), poi la migration che rimuove le policy `anon` dalle - tabelle v1.0. È l'unico passo che cambia il comportamento per tutti. +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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7107d3f..ac21a45 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,10 +46,10 @@ docs/ documentazione ufficiale 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); i permessi - di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`). La variabile - `VITE_AUTH_OBBLIGATORIA` decide se `/benvenuto` accetta ancora la selezione libera del - giocatore: finché è spenta, login e vecchio accesso convivono. +- **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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d1311de..f38f9d9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,9 +11,15 @@ qui: sta in [ROADMAP.md](ROADMAP.md). - 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). -- I permessi di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`) e non più - dalla lista di nomi in `crapp-data.ts`, che resta solo come ponte finché - `VITE_AUTH_OBBLIGATORIA` non viene acceso. +- 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)). diff --git a/docs/TODO.md b/docs/TODO.md index 450677d..8d2839f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,13 +6,11 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ ## In corso - Documentazione tecnica del progetto. -- Autenticazione Google e dashboard amministratore: implementate su `develop`, ma in dev il - login risponde ancora `provider is not enabled` perché il provider Google non è stato - acceso in Supabase. Restano da - fare, in quest'ordine: configurazione del provider Google in Supabase, applicazione delle - migration M2/M3, inserimento del primo admin in `user_roles`, collegamento dei 17 account, - e solo alla fine `VITE_AUTH_OBBLIGATORIA=true` + rimozione delle policy `anon`. - Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md). +- 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 diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 307d174..9c90a07 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,15 +3,10 @@ import type { Session } from "@supabase/supabase-js"; import { supabase } from "@/integrations/supabase/client"; /** - * Autenticazione reale con Google (DD-011). Il login non ha ancora sostituito la - * selezione del giocatore: finché `VITE_AUTH_OBBLIGATORIA` non è `true`, `/benvenuto` - * offre entrambe le strade, così la produzione continua a funzionare mentre la squadra - * collega gli account. + * Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del + * giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo + * da `user_roles` (vedi `ruoli.ts`). */ -export function authObbligatoria(): boolean { - return import.meta.env["VITE_AUTH_OBBLIGATORIA"] === "true"; -} - export function useSessione() { const [sessione, setSessione] = useState<Session | null>(null); const [pronta, setPronta] = useState(false); diff --git a/src/lib/crapp-data.ts b/src/lib/crapp-data.ts index e91ea33..2890bec 100644 --- a/src/lib/crapp-data.ts +++ b/src/lib/crapp-data.ts @@ -169,10 +169,5 @@ export function formatData(iso: string) { return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" }); } -/** Referenti che possono gestire eventi e sollecitare le risposte. */ -export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"]; - -export function isAdmin(giocatoreId: string) { - const g = giocatori.find((x) => x.id === giocatoreId); - return Boolean(g && adminNomi.includes(g.nome)); -} +/* I permessi di amministrazione stanno in `user_roles` (DD-011), non in una lista di nomi: + vedi `src/lib/ruoli.ts`. */ diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts index e37b816..bc1c25c 100644 --- a/src/lib/ruoli.ts +++ b/src/lib/ruoli.ts @@ -1,22 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import { isAdmin as nomeInListaAdmin } from "./crapp-data"; import { useSessione } from "./auth"; -import { useGiocatoreBase } from "./user-store"; export const RUOLI_KEY = ["ruolo-admin"] as const; /** - * Permessi di amministrazione. La fonte è `user_roles` nel database (DD-011): la lista di - * nomi in `crapp-data.ts` resta solo come ponte per chi non ha ancora collegato l'account, - * e sparisce quando `VITE_AUTH_OBBLIGATORIA` viene acceso in produzione. - * - * ponytail: doppia fonte temporanea, si riduce a `ruoloDb` appena l'auth è obbligatoria. + * Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011). + * Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare. */ -export function risolviAdmin(ruoloDb: boolean | null, giocatoreId: string | null): boolean { - if (ruoloDb !== null) return ruoloDb; - return giocatoreId ? nomeInListaAdmin(giocatoreId) : false; -} /** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */ async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> { @@ -33,12 +24,11 @@ async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> export function useIsAdmin(): boolean { const { utenteId } = useSessione(); - const io = useGiocatoreBase(); // Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. const query = useQuery({ queryKey: [...RUOLI_KEY, utenteId], queryFn: () => fetchRuoloAdmin(utenteId), staleTime: 30 * 60_000, }); - return risolviAdmin(query.data ?? null, io?.id ?? null); + return query.data === true; } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ea6c32d..2a8db4d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge"; import { Toaster } from "../components/ui/sonner"; import { TeamLogo } from "../components/crapp/ui-bits"; import { useGiocatoreBase } from "../lib/user-store"; +import { useSessione } from "../lib/auth"; function NotFoundComponent() { return ( @@ -145,17 +146,21 @@ function RootComponent() { const navigate = useNavigate(); const location = useLocation(); const giocatore = useGiocatoreBase(); + const { pronta, utenteId } = useSessione(); const [mounted, setMounted] = useState(false); const isBenvenuto = location.pathname === "/benvenuto"; + // Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome + // (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori + // chi ha già la sessione in localStorage. useEffect(() => { setMounted(true); - if (!giocatore && !isBenvenuto) { + if (pronta && (!giocatore || !utenteId) && !isBenvenuto) { navigate({ to: "/benvenuto" }); } - }, [giocatore, isBenvenuto, navigate]); + }, [giocatore, utenteId, pronta, isBenvenuto, navigate]); - if (!mounted) { + if (!mounted || !pronta) { return ( <div className="grid min-h-screen place-items-center bg-background"> <TeamLogo className="h-16 w-16 animate-pulse" /> diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index d4ab6cd..aaab088 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -3,8 +3,7 @@ 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 { accediConGoogle, authObbligatoria, useSessione } from "@/lib/auth"; +import { accediConGoogle, useSessione } from "@/lib/auth"; import { nomeCompleto, slotDi, @@ -13,7 +12,7 @@ import { useGiocatoriSquadra, type GiocatoreSquadra, } from "@/lib/giocatori-squadra"; -import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; +import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; export const Route = createFileRoute("/benvenuto")({ head: () => ({ @@ -65,23 +64,24 @@ function Benvenuto() { const navigate = useNavigate(); const giocatore = useGiocatoreCorrente(); const { pronta, utenteId } = useSessione(); - const { righe } = useGiocatoriSquadra(); + const { righe, daDatabase } = useGiocatoriSquadra(); const collega = useCollegaGiocatore(); const [inCorso, setInCorso] = useState(false); const mioSlot = slotDi(righe, utenteId); - // Con l'auth obbligatoria si entra solo da loggati; finché non lo è, la selezione - // diretta resta come ponte per chi non ha ancora collegato l'account (DD-011). - const puoEntrare = !!giocatore && (!!utenteId || !authObbligatoria()); + // Si entra solo da loggati e con uno slot collegato (DD-011). + const puoEntrare = !!giocatore && !!utenteId; useEffect(() => { if (puoEntrare) navigate({ to: "/" }); }, [puoEntrare, navigate]); - // L'account è già collegato a uno slot: nessuna scelta da fare. + // 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); - }, [mioSlot]); + else if (utenteId && daDatabase) resetGiocatore(); + }, [mioSlot, utenteId, daDatabase]); async function accedi() { setInCorso(true); @@ -125,25 +125,6 @@ function Benvenuto() { > <LogIn className="h-4 w-4" /> Accedi con Google </button> - - {authObbligatoria() ? null : ( - <div className="mt-10 w-full max-w-sm"> - <p className="mb-3 text-center text-xs text-muted-foreground"> - Oppure entra scegliendo il tuo nome, come prima. - </p> - <div className="space-y-2"> - {giocatori.map((g) => ( - <Scheda - key={g.id} - titolo={g.nome} - sottotitolo={`#${g.numero} · ${g.ruolo}`} - iniziali={g.iniziali} - onClick={() => impostaGiocatore(g.id)} - /> - ))} - </div> - </div> - )} </> ) : ( <> diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 91fafa2..4887b73 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { Flame, Camera, Users, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react"; +import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; import { Avatar } from "@/components/crapp/Avatar"; @@ -19,7 +19,7 @@ import { statoNotifiche, } from "@/lib/push-client"; import { resetGiocatore } from "@/lib/user-store"; -import { esci, useSessione } from "@/lib/auth"; +import { esci } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; import { Reveal } from "@/components/motion/Reveal"; @@ -42,7 +42,6 @@ function Profilo() { const votiSocial = useVotiSocial(); const g = useIo(); const admin = useIsAdmin(); - const { sessione } = useSessione(); const ultimoMese = usePresenzeUltimoMese(g?.id); const inputRef = useRef<HTMLInputElement>(null); const foto = useAvatar(g?.id); @@ -230,22 +229,12 @@ function Profilo() { ) : null} <button type="button" - onClick={() => resetGiocatore()} + onClick={logout} className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5" > - <span className="min-w-0 truncate">Cambia giocatore</span> - <Users className="h-4 w-4 text-muted-foreground" /> + <span className="min-w-0 truncate">Esci</span> + <LogOut className="h-4 w-4 text-muted-foreground" /> </button> - {sessione ? ( - <button - type="button" - onClick={logout} - className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5" - > - <span className="min-w-0 truncate">Esci</span> - <LogOut className="h-4 w-4 text-muted-foreground" /> - </button> - ) : null} </div> </Section> </> 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/test/unit/crapp-data.test.ts b/test/unit/crapp-data.test.ts index 8092981..c83b0a0 100644 --- a/test/unit/crapp-data.test.ts +++ b/test/unit/crapp-data.test.ts @@ -1,14 +1,6 @@ /** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */ import assert from "node:assert/strict"; -import { - adminNomi, - classifica, - formatData, - giocatori, - isAdmin, - statoMeta, - storicoMatch, -} from "@/lib/crapp-data"; +import { classifica, formatData, giocatori, statoMeta, storicoMatch } from "@/lib/crapp-data"; // --- rosa -------------------------------------------------------------------- assert.ok(giocatori.length > 0, "la rosa non è vuota"); @@ -32,17 +24,6 @@ for (const g of giocatori) { assert.ok(g.presenze <= g.totaliEventi, `${g.id}: presenze mai oltre gli eventi totali`); } -// --- amministratori ---------------------------------------------------------- -for (const nome of adminNomi) { - const admin = giocatori.find((g) => g.nome === nome); - assert.ok(admin, `l'amministratore ${nome} è in rosa`); - assert.equal(isAdmin(admin.id), true); -} -const nonAdmin = giocatori.find((g) => !adminNomi.includes(g.nome))!; -assert.equal(isAdmin(nonAdmin.id), false); -assert.equal(isAdmin("g999"), false, "un id inesistente non è amministratore"); -assert.equal(isAdmin(""), false); - // --- formatData -------------------------------------------------------------- assert.equal(formatData("2026-09-01"), "mar 01 settembre"); assert.equal(formatData("2026-01-31"), "sab 31 gennaio"); diff --git a/test/unit/ruoli.test.ts b/test/unit/ruoli.test.ts deleted file mode 100644 index 90d80df..0000000 --- a/test/unit/ruoli.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Check dei permessi di amministrazione: `bun test/unit/ruoli.test.ts`. */ -import assert from "node:assert/strict"; -import { risolviAdmin } from "@/lib/ruoli"; -import { adminNomi, giocatori } from "@/lib/crapp-data"; - -const referente = giocatori.find((g) => adminNomi.includes(g.nome))!; -const chiunque = giocatori.find((g) => !adminNomi.includes(g.nome))!; - -// Con una sessione attiva decide il database, e basta: la lista di nomi non conta più. -assert.equal(risolviAdmin(true, chiunque.id), true, "il ruolo nel database concede"); -assert.equal( - risolviAdmin(false, referente.id), - false, - "il ruolo nel database nega anche a chi è nella lista dei nomi", -); -assert.equal(risolviAdmin(true, null), true); - -// Senza sessione (null) resta il ponte temporaneo sulla lista di nomi. -assert.equal(risolviAdmin(null, referente.id), true); -assert.equal(risolviAdmin(null, chiunque.id), false); -assert.equal(risolviAdmin(null, null), false, "nessuna identità, nessun permesso"); -assert.equal(risolviAdmin(null, "gXX"), false, "un id sconosciuto non concede nulla"); - -console.log("ruoli: ok");