Merge branch 'develop' into main

This commit is contained in:
2026-09-01 09:28:19 +02:00
83 changed files with 6824 additions and 248 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"enabledPlugins": {
"vercel@claude-plugins-official": true,
"supabase@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}
+16
View File
@@ -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`.
+2
View File
@@ -39,3 +39,5 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
+450 -10
View File
@@ -1,10 +1,450 @@
<!-- LOVABLE:BEGIN -->
> [!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.
<!-- LOVABLE:END -->
# CrAPP - AI Development Guide
Questo documento definisce le regole che qualsiasi assistente AI (Cursor, Claude Code, Codex, ChatGPT o altri) deve seguire quando lavora su questo progetto.
---
# Obiettivo del progetto
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
L'obiettivo principale è:
- ridurre il lavoro amministrativo degli amministratori;
- aumentare il coinvolgimento dei giocatori;
- centralizzare tutte le informazioni della squadra;
- utilizzare l'intelligenza artificiale solo quando porta un reale beneficio.
---
# Prima di modificare il codice
Prima di implementare qualsiasi modifica leggere sempre:
1. docs/[README.md](http://README.md)
2. docs/[VISION.md](http://VISION.md)
3. docs/[ROADMAP.md](http://ROADMAP.md)
4. docs/[ARCHITECTURE.md](http://ARCHITECTURE.md)
5. docs/[DATABASE.md](http://DATABASE.md)
6. docs/DESIGN_[DECISIONS.md](http://DECISIONS.md)
7. docs/[TODO.md](http://TODO.md)
8. il documento interessato in docs/modules/
Inoltre, prima di iniziare una nuova attività:
- verificare lo stato attuale del repository;
- controllare le modifiche e i commit recenti;
- verificare eventuali modifiche introdotte da altri sviluppatori o assistenti AI;
- leggere la documentazione aggiornata relativa alla funzionalità interessata.
Non implementare funzionalità non documentate.
Non presumere che il progetto sia nello stesso stato dell'ultima sessione o conversazione.
---
# Workflow di sviluppo
Ogni nuova funzionalità segue sempre questo processo.
Idea
Progettazione
Documentazione
Database
Implementazione
Test
Pull Request
Merge su develop
Verifica
Merge su main
Deploy
Le funzionalità possono essere sviluppate in parallelo da persone diverse, ciascuna sul proprio branch.
---
# Git
Il repository utilizza due branch principali.
## main
Versione stabile.
Qualsiasi modifica deve mantenere l'app perfettamente funzionante.
`main` rappresenta la versione destinata alla produzione.
## develop
Branch di integrazione e test.
Le nuove funzionalità vengono integrate in `develop` prima di arrivare in `main`.
Non lavorare direttamente su `main`.
Evitare modifiche dirette a `develop`, salvo attività esplicitamente concordate.
---
# Branch di sviluppo
Ogni sviluppatore deve lavorare su un branch dedicato creato a partire da `develop`.
Esempi:
- `feature/profilo-giocatore`
- `feature/integrazione-csi`
- `fix/presenze`
- `refactor/supabase-client`
Non utilizzare lo stesso branch contemporaneamente per attività indipendenti.
Prima di iniziare un'attività verificare che il branch sia aggiornato rispetto a `develop`.
---
# Integrazione delle modifiche
Le modifiche significative devono essere integrate tramite Pull Request verso `develop`.
Una Pull Request dovrebbe permettere di capire:
- cosa è stato modificato;
- perché è stato modificato;
- quali file o moduli sono coinvolti;
- se il database è stato modificato;
- quali test sono stati eseguiti;
- eventuali rischi o conseguenze.
Prima del merge verificare eventuali conflitti con il lavoro sviluppato nel frattempo dagli altri collaboratori.
---
# Tracciabilità delle modifiche
Ogni modifica significativa deve lasciare una traccia nel progetto.
Devono essere utilizzati:
- commit con messaggi descrittivi;
- Pull Request per l'integrazione;
- [CHANGELOG.md](http://CHANGELOG.md) quando una modifica deve essere registrata nella cronologia del progetto;
- PROJECT_[STATE.md](http://STATE.md) quando cambia lo stato generale del progetto;
- DESIGN_[DECISIONS.md](http://DECISIONS.md) per decisioni architetturali significative.
La documentazione deve permettere a uno sviluppatore o a un assistente AI di ricostruire cosa è successo senza dipendere dalla cronologia delle conversazioni.
---
# Aggiornamento del contesto dopo la sincronizzazione
Quando vengono scaricate modifiche da GitHub, l'assistente AI deve considerare il repository come fonte di verità.
Prima di iniziare una nuova attività deve:
1. verificare i nuovi commit;
2. identificare le modifiche rilevanti;
3. leggere la documentazione modificata;
4. verificare eventuali modifiche al database;
5. tenere conto delle nuove decisioni architetturali.
Non ignorare modifiche introdotte da altri collaboratori.
Non sovrascrivere modifiche esistenti senza averne compreso lo scopo.
---
# Architettura
Frontend
- React
- TypeScript
- TanStack Start
- Tailwind CSS
Backend
- Supabase
Hosting
- Vercel
Repository
- GitHub
---
# Database
Il database utilizza Supabase.
Regole:
- non eliminare tabelle esistenti;
- non modificare lo schema senza creare una migration;
- preferire strutture scalabili;
- evitare duplicazione dei dati;
- non modificare migration già applicate;
- ogni modifica allo schema deve essere rappresentata da una nuova migration.
Fare sempre riferimento a:
docs/[DATABASE.md](http://DATABASE.md)
---
# Componenti
Preferire:
- componenti piccoli;
- componenti riutilizzabili;
- responsabilità singola;
- codice semplice da mantenere.
Evitare duplicazioni.
Prima di creare un nuovo componente verificare se esiste già un componente riutilizzabile.
---
# Interfaccia
Lo stile dell'app deve rimanere coerente.
Principi:
- semplice;
- moderna;
- pulita;
- veloce;
- ottimizzata per smartphone;
- poche schermate;
- pochi click.
---
# Documentazione
Ogni nuova funzionalità deve essere documentata prima dello sviluppo.
La documentazione dei moduli si trova in:
docs/modules/
Aggiornare sempre, quando necessario:
- [ROADMAP.md](http://ROADMAP.md)
- [CHANGELOG.md](http://CHANGELOG.md)
- [TODO.md](http://TODO.md)
- [DATABASE.md](http://DATABASE.md) (se il database cambia)
- DESIGN_[DECISIONS.md](http://DECISIONS.md) (se si prende una decisione architetturale importante)
- PROJECT_[STATE.md](http://STATE.md) (se cambia lo stato generale del progetto)
---
# Struttura della documentazione
La cartella `docs/` rappresenta la documentazione ufficiale del progetto.
## Documenti principali
- [README.md](http://README.md) → panoramica del progetto
- [VISION.md](http://VISION.md) → obiettivi e filosofia
- [ROADMAP.md](http://ROADMAP.md) → evoluzione prevista
- [ARCHITECTURE.md](http://ARCHITECTURE.md) → architettura tecnica
- [DATABASE.md](http://DATABASE.md) → struttura del database
- DESIGN_[DECISIONS.md](http://DECISIONS.md) → registro delle decisioni di progetto
- [CHANGELOG.md](http://CHANGELOG.md) → cronologia delle modifiche
- [TODO.md](http://TODO.md) → attività pianificate
- PROJECT_[STATE.md](http://STATE.md) → stato attuale del progetto
## Moduli
La cartella `docs/modules/` contiene una specifica funzionale per ogni modulo dell'applicazione.
Ogni nuovo modulo deve essere progettato e documentato prima dell'implementazione.
---
# Regola anti-regressione
Le nuove versioni devono principalmente aggiungere funzionalità.
Non riscrivere o modificare profondamente moduli già funzionanti senza una motivazione esplicita e una verifica degli impatti.
Evitare refactoring trasversali durante lo sviluppo di nuove funzionalità, salvo quando sono necessari per la funzionalità stessa.
Prima di modificare un modulo esistente verificare quali altre parti dell'app lo utilizzano.
---
# Regole per il database e le migration
Le migration già applicate sono parte della storia del database e non devono essere riscritte.
Per modificare il database:
1. progettare la modifica;
2. documentarla quando necessario;
3. creare una nuova migration;
4. testarla;
5. applicarla all'ambiente di sviluppo;
6. verificare l'assenza di regressioni;
7. solo successivamente applicarla all'ambiente di produzione.
---
# Regole
L'AI non deve:
- introdurre librerie senza necessità;
- modificare il database senza motivazione;
- eliminare funzionalità esistenti;
- modificare il comportamento dell'app senza richiesta esplicita;
- sovrascrivere modifiche di altri collaboratori senza comprenderle;
- riscrivere migration già applicate;
- lavorare direttamente su `main`;
- assumere che il repository sia invariato rispetto all'ultima sessione.
L'AI deve:
- spiegare le modifiche importanti;
- mantenere compatibilità con il codice esistente;
- privilegiare la semplicità;
- riutilizzare i componenti esistenti;
- controllare il lavoro recente degli altri collaboratori;
- mantenere aggiornata la documentazione quando necessario;
- segnalare conflitti, rischi e possibili regressioni prima di modificare parti sensibili.
---
# Filosofia del progetto
Prima di scrivere codice chiedersi sempre:
Questa modifica rende CrAPP più semplice?
Riduce il lavoro degli amministratori?
Migliora l'esperienza dei giocatori?
È coerente con la documentazione?
Riduce oppure aumenta la complessità futura?
Se almeno una risposta è negativa, rivalutare la soluzione proposta.
+26
View File
@@ -0,0 +1,26 @@
# CLAUDE.md
Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui.
La documentazione tecnica è indicizzata in [docs/README.md](docs/README.md); l'architettura
in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono
anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code. Vale per qualsiasi aggiunta o
modifica: prima di dire che hai finito, esegui la checklist «Fine lavoro» di `AGENTS.md`.
## Comandi
```bash
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run format # prettier --write .
npm run test # suite di test (test/); npm run test:all per quella completa
npx supabase start # database locale in Docker (migration applicate + seed)
npx supabase stop # spegne i container
npx supabase db reset # ricrea il database locale da zero
npx supabase db push # applica le migration al progetto cloud
```
Verifica minima prima di consegnare: `npm run lint` + `npm run test`.
+109
View File
@@ -0,0 +1,109 @@
# Project State
Ultimo aggiornamento: 30/08/2026
## Stato generale
Fase corrente:
Backend migrato al nuovo Supabase proprietario. M1 completata. M2 e M3 scritte e da applicare.
Autenticazione Google, dashboard amministratore e Profilo Giocatore (lato giocatore e lato
admin) implementati su `develop`, da attivare in produzione seguendo i passaggi più sotto.
---
## Infrastruttura
- GitHub configurato con branch `main` e `develop`
- Cursor come ambiente di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 12 migration locali applicate con successo al nuovo database
- Sviluppo locale verificato con il nuovo Supabase
- Preview Vercel di `develop` verificata con successo (presenza scritta su `risposte_presenze` confermata nel nuovo database)
- Produzione (`main`): non ancora verificata in questa fase
---
## Backend
- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`)
- Lovable Cloud: non più backend operativo di CrAPP
- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare
---
## Database
- Schema v1.0 + M1 applicati al nuovo Supabase
- `public.giocatori_squadra`: 17 giocatori iniziali presenti
---
## Moduli completati
- Squadra
- Presenze
- Badge
- Scout Live
- Pagelle
- MVP
- Notifiche
- Profilo Giocatore (su `develop`, specifica in `docs/modules/profilo-giocatore.md`)
---
## Autenticazione e dashboard amministratore
Implementate su `develop`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
permessi di amministrazione arrivano solo da `user_roles`.
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
Google» risponde
```
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
```
e **nessuno entra nell'app**, né in dev né sulla preview di `develop`. Il passo 1 qui sotto
va fatto prima di mandare questa versione in produzione.
Passaggi in ordine, nessuno dei quali è reversibile a metà:
1. **Provider Google in Supabase** — Google Cloud Console: consent screen *External* (scope
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità *Testing*
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
*Web application* con redirect URI
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
secret in *Authentication → Providers → Google*. In *URL Configuration*: Site URL di
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
`[auth.external.google]` di `supabase/config.toml`, le due variabili
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
`http://127.0.0.1:54321/auth/v1/callback`.
2. **Migration M2 e M3** (`supabase db push`). Sono `CREATE` puri: si possono applicare in
produzione senza toccare il comportamento attuale.
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<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**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Gestione tesseramenti CSI: la raccolta dati e l'export CSV sono pronti, manca il
tracciamento di chi è già tesserato (numero e data di tessera).
---
## Note
Il progetto segue una metodologia document-first.
Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata.
+81 -104
View File
@@ -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 unapp mobile per la squadra di pallavolo CRAP Volley, con nome CrAPP, disponibile per Android e iOS. Lobiettivo è creare unapp 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 lapp 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 <this-repository-url>
cd <repository-name>
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
```
+123
View File
@@ -0,0 +1,123 @@
# Architettura del progetto
Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di
riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama.
## Stack
| Livello | Tecnologie |
|---|---|
| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn |
| Backend | Supabase (PostgreSQL, Auth, Storage) |
| Hosting | Vercel |
| Versionamento | Git, GitHub |
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
`minimumReleaseAgeExcludes` richiede conferma esplicita.
## Struttura del progetto
```
src/
components/ componenti condivisi (crapp/, ui/, motion/)
routes/ routing file-based
lib/ logica di dominio, un file per modulo
integrations/ client Supabase e integrazioni esterne
hooks/
assets/
supabase/ migration SQL
test/ suite di test (unit, integration, end-to-end)
docs/ documentazione ufficiale
```
## Punti fermi
- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si
modifica a mano.
- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che
include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias
`@``src/`. **Non ri-aggiungere questi plugin**: l'app si rompe.
- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare
gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza
`renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui
server functions, `attachSupabaseAuth`).
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file
generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo
schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite
`client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`.
- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica
strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità
del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di
amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`).
## Livello dati
Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`,
`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern
ricorrente:
- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
disattivati, `retry: 1`);
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica
eccezione oggi: `scout-live.ts`);
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le
route consumano `useRosa()`, non i singoli moduli.
Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il
backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)).
Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di
`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md).
Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La
gamification deve restare equa tra ruoli (DD-008).
La rosa è tuttora **hardcoded** in `src/lib/crapp-data.ts` (`rosaCSI`); la migrazione verso
la tabella `giocatori_squadra` è in corso — vedi DD-015 e DD-016.
## UI
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`,
`StatTile`), primitive shadcn in `src/components/ui/`, animazioni in
`src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click.
## Comandi
```bash
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run format # prettier --write .
npm run test # test unit (veloci, senza rete né database)
npm run test:integration # route server vere
npm run test:e2e # percorsi sull'app servita
npm run test:all # tutto
```
Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud:
```bash
npx supabase start # avvia lo stack locale e applica tutte le migration
npx supabase stop # spegne i container
npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql
npx supabase db push # applica le migration al progetto cloud
```
`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché
il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto
dove provare le migration distruttive senza toccare i dati veri.
## Branch e flusso di sviluppo
- `main` → produzione, deploy automatico su Vercel.
- `develop` → sviluppo; si lavora qui, mai direttamente su `main` (DD-003).
```
develop → test → merge su main → deploy automatico su Vercel
```
+60
View File
@@ -0,0 +1,60 @@
# Changelog
Tutte le modifiche significative del progetto vengono registrate in questo documento, in
ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete
qui: sta in [ROADMAP.md](ROADMAP.md).
## Versione attuale — agosto 2026
### Autenticazione e dashboard amministratore (su `develop`, non ancora in produzione)
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
giocatore stesso (DD-016 regola 2).
- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con
Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile
`VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`.
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
per amministrare.
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
**Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede
l'app vuota.
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
documento, certificato medico e foto tessera con le relative scadenze
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
certificato e foto tessera, export CSV per il tesseramento CSI.
- Migration `m2_profili_giocatore` (tabella dei profili) e `m3_bucket_profili` (bucket
privato), entrambe additive.
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
compila i dati personali al posto di un giocatore e scollega un account da un profilo
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
policy di M1 e M2 lo consentivano già.
### Test
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun,
senza nuove dipendenze: `npm run test` e `npm run test:all`.
- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp
illeggibile restava bloccata per sempre invece di scadere.
### Collegamento CSI
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
- La pagina Campionato non usa più dati dimostrativi.
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
### Infrastruttura
- Migrazione completa da Lovable a sviluppo locale.
- Configurazione Git.
- Repository GitHub indipendente.
- Deploy automatico tramite Vercel.
- Branch main e develop.
## Versione 1.0 — luglio 2026
Prima versione usata dalla squadra. Funzionalità incluse: vedi
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
+66
View File
@@ -0,0 +1,66 @@
# Database CrAPP
Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo
sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
non in questo file.
## Anagrafica e utenti
| Tabella | Scopo | Note |
|---|---|---|
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1``gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento all'account (`auth_user_id`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
## Storage
| Bucket | Scopo | Note |
|---|---|---|
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<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 |
|---|---|---|
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout
| Tabella | Scopo | Note |
|---|---|---|
| `scout_sessioni` | Sessioni di Scout Live: una sessione corrisponde a una partita. | **Non ancora usata dal codice**: oggi lo stato della sessione vive in `localStorage` (`src/lib/scout-live.ts`, `scout-store.ts`) e sul database finiscono solo le azioni in `scout_live`. |
| `scout_live` | Eventi registrati durante lo Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). |
## Votazioni
| Tabella | Scopo | Note |
|---|---|---|
| `mvp_voti` | Voti MVP assegnati a fine partita. | |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. |
| `badge_social_voti` | Voti social per i badge. | |
## Turni e notifiche
| Tabella | Scopo | Note |
|---|---|---|
| `turni_palloni` | Gestione dei turni palloni. | |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Storico dei promemoria inviati. | |
## Funzioni speciali
| Tabella | Scopo | Note |
|---|---|---|
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
## Badge
Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a
partire dai dati esistenti (DD-007).
+517
View File
@@ -0,0 +1,517 @@
# Registro delle decisioni di progetto
Questo documento raccoglie le **decisioni importanti** prese nel corso della vita di CrAPP: scelte che hanno influito sulla direzione del prodotto, sullorganizzazione del lavoro o su come lapp si evolve nel tempo.
Non descrive *come* è fatto il codice. Per quello esistono `ARCHITECTURE.md` e `DATABASE.md`.
Serve a rispondere a domande del tipo:
- *Perché abbiamo scelto così?*
- *Cosa avevamo escluso e perché?*
- *Quando conviene riaprire una decisione?*
---
## Indice
**Accettate**
| ID | Titolo |
|---|---|
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop |
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
**In valutazione**
| ID | Titolo |
|---|---|
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
---
## Come usare questo registro
Ogni decisione segue lo stesso schema:
| Campo | Significato |
|---|---|
| **Data** | Quando la decisione è stata presa o confermata |
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
| **Contesto** | Quale problema o opportunità avevamo di fronte |
| **Decisione** | Cosa abbiamo scelto di fare |
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
| **Riesame** | Quando ha senso riconsiderarla |
**Quando aggiungere una voce**
- una scelta influisce su più moduli o su più release;
- escludiamo unalternativa non ovvia;
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
- cambiamo una decisione precedente.
**Quando non serve**
- dettagli implementativi locali;
- scelte estetiche minori;
- bugfix o correzioni puntuali.
**Come registrare una nuova decisione**
Copiare [`_template-dd.md`](_template-dd.md) in fondo al documento, assegnare il primo ID
libero e aggiungerlo all'indice.
---
## Decisioni accettate
---
### DD-001 — CrAPP deve restare indipendente da Lovable
**Data:** luglio 2026
**Stato:** Accettata
**Contesto**
Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi.
**Decisione**
Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team.
**Alternative scartate**
- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno.
- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già.
**Conseguenze**
- Maggiore libertà e responsabilità per il team.
- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco.
- Lapp deve poter girare anche fuori dallecosistema 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 lavvio 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 lapp 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**
LAI è attraente ma può complicare lapp, aumentare i costi e creare aspettative irrealistiche.
**Decisione**
Usare lAI solo quando riduce lavoro agli admin o migliora concretamente lesperienza 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 lAI diventa economica e affidabile per casi duso chiari (es. generazione allenamenti).
---
### DD-007 — Badge calcolati dallapp, 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** dallapplicazione 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 dattacco). 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).
- Lexport 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 lautomazione.
---
### 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 lapp identifica lutente 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**
Lapp 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à: lapp non deve dipendere da servizi esclusivi
**Data:** luglio 2026
**Stato:** Accettata
**Contesto**
La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro.
**Decisione**
CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti.
**Alternative scartate**
- Accettare lock-in per velocità → contrario alla lunga vita del progetto.
**Conseguenze**
- Supabase va bene perché è PostgreSQL e self-hostable.
- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler.
**Riesame**
Se si adotta un servizio che viola questa regola.
---
### DD-016 — Schema dati Profilo Giocatore v1.1 (F0)
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con lanagrafica 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 lautenticazione 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 lanagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
2. Lassociazione **`auth_user_id` ↔ giocatore** è unoperazione **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 didentità.
- 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 dellanagrafica: `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 M1M3 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
**Riesame**
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
- Quando si pianifica la convergenza verso UUID (DD-012, post v1.1).
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
---
### DD-017 — L'amministratore può compilare i dati al posto del giocatore
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
**Decisione**
Dalla dashboard amministratore, un admin può:
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
3. **scollegare** un account da un profilo, liberando lo slot.
Restano fuori, e non cambiano:
- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
**Alternative scartate**
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
**Conseguenze**
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
- Il completamento del profilo smette di essere un indicatore di *chi ha risposto* e diventa un indicatore di *quali dati mancano*, chiunque li abbia inseriti.
**Riesame**
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
- Se serve tracciare *chi* ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
---
## Decisioni in valutazione
---
### DD-014 — Convergenza schema database (eventi e presenze)
**Data:**
**Stato:** In valutazione
**Contesto**
Esistono due modelli paralleli: tabelle “legacy” usate dallapp (`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 lanagrafica 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.
---
+36
View File
@@ -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).
+44
View File
@@ -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.
+53
View File
@@ -0,0 +1,53 @@
# Roadmap
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
il *cosa*: `CHANGELOG.md` registra *quando* una voce è stata rilasciata, `TODO.md` cosa si
sta facendo adesso.
## Versione 1.0 — rilasciata
- [x] Gestione squadra
- [x] Calendario
- [x] Presenze
- [x] Serie di presenze
- [x] Scout Live
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Obiettivi di squadra
- [x] Notifiche Push (promemoria intelligenti)
## Versione 1.1
Le voci spuntate sono implementate su `develop` e non ancora attive in produzione: lo stato
di attivazione sta in [PROJECT_STATE.md](../PROJECT_STATE.md).
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
certificati resta un'estensione futura
- [ ] Gestione tesseramenti CSI — raccolta dati ed export CSV pronti, manca il tracciamento
di chi è già tesserato (numero e data di tessera)
- [x] Dashboard amministratore
- [x] Download CSV dati
## Versione 1.2
- [ ] Database esercizi
- [ ] AI Allenamenti
- [ ] Archivio allenamenti
## Versione 2.0
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica
- [x] Risultati campionato
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
## Idee future
- [ ] Gestione quote
- [ ] Calendario Google
- [ ] Backup automatici
- [ ] Analisi statistiche avanzate
- [ ] Widget meteo
- [ ] Analisi Scout con AI
+43
View File
@@ -0,0 +1,43 @@
# TODO
Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in
[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure.
## In corso
- Documentazione tecnica del progetto.
- Autenticazione Google e dashboard amministratore: il codice è completo su `develop` e il
login è ora l'unica via d'accesso. Restano i passaggi di configurazione, in quest'ordine:
provider Google in Supabase (senza, nessuno entra), migration M2/M3, primo admin in
`user_roles`, collegamento dei 17 account, e infine la migration M4 che chiude gli accessi
`anon`. Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md).
## Prossimo
- Gestione tesseramenti CSI (roadmap v1.1): la raccolta dati e l'export CSV ci sono, manca
il tracciamento di chi è già tesserato (numero e data di tessera).
Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo`
in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e
la dashboard amministratore legge quei dati.
## Debito di documentazione
Moduli v1.0 in produzione senza scheda in [modules/](modules/) — DD-002 ne prevede la
retro-documentazione: Presenze, Scout Live, Badge, Pagelle, MVP, Palloni, Obiettivi di
squadra, Notifiche, Serie di presenze, Infortuni (`src/lib/infortuni.ts`, usato ma non
documentato in nessun punto).
Non documentate nemmeno le route API pubbliche in `src/routes/api/public/` (`csi`,
`promemoria-palloni`, `push-config`, `push-messaggio`, `push-subscribe`,
`sollecita-presenze`).
## Manutenzione ricorrente
- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27
(vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
## Backlog
- AI Allenamenti (roadmap v1.2).
- Backup automatici (roadmap, idee future).
+32
View File
@@ -0,0 +1,32 @@
# Visione del progetto
## Missione
CrAPP nasce con un obiettivo semplice:
Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi.
## Principi del progetto
Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
- Ridurre il lavoro amministrativo.
- Migliorare il coinvolgimento della squadra.
- Centralizzare tutte le informazioni in un'unica piattaforma.
- Automatizzare le attività ripetitive.
- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio.
## Filosofia
CrAPP deve essere:
- Semplice
- Veloce
- Divertente
- Intuitiva
- Accessibile da smartphone
- Utilizzabile anche da persone poco esperte
## Obiettivo finale
Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione.
+20
View File
@@ -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]
@@ -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;
@@ -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)
);
+96
View File
@@ -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.
+221
View File
@@ -0,0 +1,221 @@
# Modulo — Profilo Giocatore
## Obiettivo
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel.
## Utenti
### Giocatore
Può:
- visualizzare il proprio profilo
- modificare i propri dati personali
- aggiornare il certificato medico
- aggiornare i documenti
- caricare le immagini richieste
### Amministratore
Può:
- visualizzare il profilo di tutti i giocatori
- scaricare documenti e certificati
- esportare i dati necessari al tesseramento CSI
- verificare lo stato di completamento dei profili
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo)
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
- scollegare un account da un profilo, liberando lo slot
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
responsabilità del giocatore che li fornisce.
## Flusso utente
### Primo accesso
1. Login tramite Google oppure Email. *Implementato con il solo Google: la squadra ha tutti
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
schermata) il giorno che serve.*
2. Selezione del proprio giocatore.
3. Accesso alla Home.
Se il profilo non è completo compare automaticamente un widget di completamento.
## Home
Il giocatore visualizza un widget dedicato.
### Completa il tuo profilo
Viene mostrata una barra di avanzamento (esempio: *Profilo completato — 85%*), composta dalle seguenti sezioni.
- Dati personali
- Documento di identità
- Certificato medico
- Foto tessera
Quando tutte le sezioni sono complete il widget scompare automaticamente.
## Profilo
Il profilo viene suddiviso in cinque aree.
### Dati Giocatore
**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori.
- Nome
- Cognome
- Numero di maglia
- Ruolo
**Dati personali** — modificabili dal giocatore.
- Data di nascita
- Luogo di nascita
- Indirizzo di residenza
- Telefono
- Email
### Documento di identità
Campi.
- Tipo documento
- Numero documento
- Rilasciato da
- Data emissione
- Data scadenza
Upload.
- Foto fronte
- Foto retro
### Certificato medico
Campi.
- Data di scadenza
Upload.
- Certificato medico
Il giocatore può aggiornare liberamente sia la data sia il file.
Lo storico non viene mantenuto nella prima versione.
### Foto tessera
Upload di una fotografia formato tessera.
Utilizzata dagli amministratori per il tesseramento CSI.
### Statistiche
Sezione già presente. Contiene.
- Presenze
- Voto medio
- MVP
- Serie
- Altre statistiche disponibili
### Badge
Sezione già presente.
Contiene tutti i badge ottenuti e quelli ancora da sbloccare.
### Impostazioni
Contiene.
- Logout
- Preferenze notifiche
- Impostazioni applicazione
## Dashboard amministratore
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
Profilo → Impostazioni).
Per ogni giocatore vengono mostrati.
- Stato del profilo
- Certificato medico
- Documento di identità
- Foto tessera
Azioni disponibili.
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
- Scarica certificato
- Scarica documento
- Scarica foto tessera
- Modifica dati squadra e dati personali del giocatore (DD-017)
- Scollega account, per liberare uno slot assegnato per errore
## Esportazione CSI
Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento.
Campi esportati.
- Nome
- Cognome
- Data di nascita
- Luogo di nascita
- Indirizzo
- Telefono
- Email
- Tipo documento
- Numero documento
- Rilasciato da
- Data emissione
- Data scadenza
## Completamento profilo
Ogni sezione contribuisce alla percentuale di completamento.
| Sezione | Peso |
|---|---|
| Dati personali | 30% |
| Documento di identità | 30% |
| Certificato medico | 30% |
| Foto tessera | 10% |
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
## Permessi
**Giocatore** — può modificare esclusivamente il proprio profilo.
**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
Non carica file al posto di altri.
## Versione 1
- Profilo giocatore
- Completamento profilo
- Gestione dati personali
- Documento di identità
- Certificato medico
- Foto tessera
- Dashboard amministratore
- Esportazione CSV CSI
## Versioni future
- Storico certificati medici
- Gestione documenti aggiuntivi
- Consensi privacy
- Firma digitale
- Verifica automatica documenti
-14
View File
@@ -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.
-16
View File
@@ -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`.
+4 -2
View File
@@ -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`
+5 -1
View File
@@ -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",
@@ -0,0 +1,417 @@
import { useRef, useState } from "react";
import { Link } from "@tanstack/react-router";
import { Check, Eye, Loader2, Upload } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Section } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal";
import {
caricaFile,
scaricaFile,
useProfili,
useSalvaProfilo,
type SezioneFile,
} from "@/lib/profili";
import {
completamento,
profiloVuoto,
sezioniComplete,
type Profilo,
type Sezione,
} from "@/lib/profili-core";
const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
return (
<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>
);
}
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,
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>
);
}
/**
* 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.
*/
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 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">
<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>
<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")}
/>
}
fileFoto={
<>
<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>
);
}
+4 -2
View File
@@ -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}
+3 -2
View File
@@ -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;
@@ -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;
+56
View File
@@ -0,0 +1,56 @@
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 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 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;
}
+2 -7
View File
@@ -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`. */
+163
View File
@@ -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<string, string> = {
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 `<td>`): 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(/<table[\s\S]*?<\/table>/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(/<tr[\s\S]*?<\/tr>/gi) ?? []) {
const celle = (riga.match(/<td[\s\S]*?<\/td>/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);
}
+21
View File
@@ -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<DatiCsi>({
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,
});
}
+192
View File
@@ -0,0 +1,192 @@
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 };
}
/** 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
* 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,
),
);
},
});
}
+205
View File
@@ -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)}`;
}
+117
View File
@@ -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;
}
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth";
export const RUOLI_KEY = ["ruolo-admin"] as const;
/**
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
*/
/** `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();
// 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 query.data === true;
}
+10 -9
View File
@@ -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");
}
+13 -3
View File
@@ -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(() => {
+42
View File
@@ -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'
@@ -19,6 +20,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'
@@ -30,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',
@@ -75,6 +82,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',
@@ -105,6 +117,7 @@ const ApiPublicSollecitaPresenzeRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/benvenuto': typeof BenvenutoRoute
'/calendario': typeof CalendarioRoute
'/classifica': typeof ClassificaRoute
@@ -114,6 +127,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
@@ -122,6 +136,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/benvenuto': typeof BenvenutoRoute
'/calendario': typeof CalendarioRoute
'/classifica': typeof ClassificaRoute
@@ -131,6 +146,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
@@ -140,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
@@ -149,6 +166,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
@@ -159,6 +177,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/admin'
| '/benvenuto'
| '/calendario'
| '/classifica'
@@ -168,6 +187,7 @@ export interface FileRouteTypes {
| '/squadra'
| '/allenamento/$id'
| '/partita/$id'
| '/api/public/csi'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-messaggio'
@@ -176,6 +196,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/admin'
| '/benvenuto'
| '/calendario'
| '/classifica'
@@ -185,6 +206,7 @@ export interface FileRouteTypes {
| '/squadra'
| '/allenamento/$id'
| '/partita/$id'
| '/api/public/csi'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-messaggio'
@@ -193,6 +215,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/admin'
| '/benvenuto'
| '/calendario'
| '/classifica'
@@ -202,6 +225,7 @@ export interface FileRouteTypes {
| '/squadra'
| '/allenamento/$id'
| '/partita/$id'
| '/api/public/csi'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-messaggio'
@@ -211,6 +235,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
BenvenutoRoute: typeof BenvenutoRoute
CalendarioRoute: typeof CalendarioRoute
ClassificaRoute: typeof ClassificaRoute
@@ -220,6 +245,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
@@ -236,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'
@@ -299,6 +332,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'
@@ -339,6 +379,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
BenvenutoRoute: BenvenutoRoute,
CalendarioRoute: CalendarioRoute,
ClassificaRoute: ClassificaRoute,
@@ -348,6 +389,7 @@ const rootRouteChildren: RootRouteChildren = {
SquadraRoute: SquadraRoute,
AllenamentoIdRoute: AllenamentoIdRoute,
PartitaIdRoute: PartitaIdRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute,
+8 -3
View File
@@ -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 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" />
+419
View File
@@ -0,0 +1,419 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
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 { 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,
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",
};
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">
<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>
);
}
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({
g,
profilo,
oggi,
indice,
}: {
g: GiocatoreSquadra;
profilo: Profilo | undefined;
oggi: string;
indice: number;
}) {
const [aperta, setAperta] = useState(false);
const nome = nomeCompleto(g);
const { ruolo, numero } = g;
const perc = completamento(profilo);
const sezioni = sezioniComplete(profilo);
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi);
const retro = statoScadenza(profilo?.documentoScadenza, profilo?.documentoRetroPath, oggi);
return (
<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 ? <ModificaGiocatore g={g} profilo={profilo} /> : 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} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
))}
</div>
)}
</Section>
</>
);
}
+56
View File
@@ -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<string> {
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<DatiCsi> {
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);
},
},
},
});
+118 -30
View File
@@ -1,66 +1,154 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { LogIn } from "lucide-react";
import { toast } from "sonner";
import { TeamLogo } from "@/components/crapp/ui-bits";
import { giocatori } from "@/lib/crapp-data";
import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
import { accediConGoogle, useSessione } from "@/lib/auth";
import {
nomeCompleto,
slotDi,
slotLiberi,
useCollegaGiocatore,
useGiocatoriSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
export const Route = createFileRoute("/benvenuto")({
head: () => ({
meta: [
{ title: "Benvenuto — CrAPP" },
{ title: "Benvenuto — CrAPP DEVELOP" },
{
name: "description",
content: "Seleziona il tuo profilo giocatore per iniziare.",
content: "Accedi e collega il tuo profilo giocatore per iniziare.",
},
{ property: "og:title", content: "Benvenuto — CrAPP" },
{ property: "og:title", content: "Benvenuto — CrAPP DEVELOP" },
{
property: "og:description",
content: "Seleziona il tuo profilo giocatore per iniziare.",
content: "Accedi e collega il tuo profilo giocatore per iniziare.",
},
],
}),
component: Benvenuto,
});
function Scheda({
titolo,
sottotitolo,
onClick,
iniziali,
}: {
titolo: string;
sottotitolo: string;
onClick: () => void;
iniziali: string;
}) {
return (
<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, daDatabase } = useGiocatoriSquadra();
const collega = useCollegaGiocatore();
const [inCorso, setInCorso] = useState(false);
const mioSlot = slotDi(righe, utenteId);
// Si entra solo da loggati e con uno slot collegato (DD-011).
const puoEntrare = !!giocatore && !!utenteId;
useEffect(() => {
if (giocatore) {
navigate({ to: "/" });
if (puoEntrare) navigate({ to: "/" });
}, [puoEntrare, navigate]);
// Chi sei lo dice lo slot collegato all'account, non quello che c'è in localStorage:
// senza slot la scelta salvata dalla vecchia selezione libera va buttata.
useEffect(() => {
if (mioSlot) impostaGiocatore(mioSlot.id);
else if (utenteId && daDatabase) resetGiocatore();
}, [mioSlot, utenteId, daDatabase]);
async function accedi() {
setInCorso(true);
try {
await accediConGoogle();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Accesso non riuscito");
setInCorso(false);
}
}, [giocatore, navigate]);
}
async function reclama(g: GiocatoreSquadra) {
if (!utenteId) return;
try {
await collega.mutateAsync({ giocatoreId: g.id, utenteId });
impostaGiocatore(g.id);
} catch {
toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore.");
}
}
const liberi = slotLiberi(righe);
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
<TeamLogo className="h-20 w-20" />
<h1 className="mt-6 text-center font-display text-4xl uppercase leading-none">
Benvenuto in CrAPP TEST
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>
</>
) : (
<>
<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>
);
}
+3 -2
View File
@@ -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"
+41 -16
View File
@@ -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 (
<>
<PageHeader titolo="Campionato" sottotitolo="Girone C · CSI Milano" />
<PageHeader
titolo="Campionato"
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
/>
<div className="px-5 pt-4">
<div className="flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5 text-accent" />
Dati CSI aggiornati oggi alle 08:40 (demo)
{csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
</div>
<div className="mt-2">
<ScoutEntry variante="compatto" />
@@ -58,7 +83,7 @@ function Classifica() {
<span className="text-center">Pt</span>
</div>
{classifica.map((r) => {
const noi = r.squadra === "CRAP Volley";
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
return (
<div
key={r.pos}
@@ -82,7 +107,7 @@ function Classifica() {
</div>
</Section>
<Section titolo="Ultimi risultati del girone">
<Section titolo="Ultimi risultati ufficiali">
<div className="space-y-2">
{risultati.map((m) => (
<div
@@ -101,4 +126,4 @@ function Classifica() {
</Section>
</>
);
}
}
+4 -2
View File
@@ -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" />
+22 -14
View File
@@ -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>
+4 -2
View File
@@ -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))}
+29 -5
View File
@@ -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, 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 } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { Reveal } from "@/components/motion/Reveal";
export const Route = createFileRoute("/profilo")({
@@ -38,6 +41,7 @@ export const Route = createFileRoute("/profilo")({
function Profilo() {
const votiSocial = useVotiSocial();
const g = useIo();
const admin = useIsAdmin();
const ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null);
const foto = useAvatar(g?.id);
@@ -72,6 +76,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 +181,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,13 +218,22 @@ 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()}
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>
</div>
</Section>
+4 -2
View File
@@ -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" />}
+1
View File
@@ -0,0 +1 @@
main
+22 -1
View File
@@ -1 +1,22 @@
project_id = "hetycilxgkdmccelwerq"
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)"
@@ -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');
@@ -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)
)
);
@@ -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;
@@ -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';
+37
View File
@@ -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>';
+51
View File
@@ -0,0 +1,51 @@
# Test
Suite eseguita con **bun**, senza framework né dipendenze aggiuntive: ogni file è
uno script che usa `node:assert/strict` e termina con codice diverso da zero se
un controllo fallisce.
```bash
npm run test # unit (veloce, nessuna rete, nessun database)
npm run test:integration # route server vere
npm run test:e2e # percorsi sull'app servita
npm run test:all # tutto
bun test/unit/badges.test.ts # un singolo file
```
Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test
non può inquinare gli altri.
## Struttura
| Cartella | Cosa verifica | Serve rete? |
|---|---|---|
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Convenzioni
- **Nessun test scrive sul database.** Integration ed e2e fanno solo letture e
verifiche di validazione: si possono lanciare anche contro l'ambiente reale.
L'unica eccezione apparente è `schema-profili`, che *tenta* scritture da utente
anonimo proprio per dimostrare che la RLS le respinge, e poi rilegge la riga per
verificare che non sia cambiata: su un UPDATE a zero righe PostgREST risponde 2xx,
quindi lo stato conta più del codice di risposta.
- I test legati alle migration M2/M3 si **saltano da soli** dove quelle migration non
sono ancora applicate, indicandolo nel motivo. Per vederli tutti verdi serve un
database che le contenga: `npx supabase start` ne crea uno in locale.
- `integration` ed `e2e` avviano da soli il server di sviluppo. Per usarne uno già
attivo: `BASE_URL=http://localhost:8080 npm run test:e2e`.
- Le variabili d'ambiente vengono lette da `.env`; i nomi senza prefisso
(`SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`) sono derivati da quelli `VITE_*`.
I test che richiedono `SUPABASE_SERVICE_ROLE_KEY` si saltano da soli se manca.
- Il check del parsing CSI può girare contro il portale reale:
`CSI_LIVE=1 bun test/unit/csi-core.test.ts`.
## Limite noto
L'app renderizza i contenuti dopo l'idratazione: il server invia solo il guscio
(titolo, meta, splash). Gli e2e verificano quindi le risposte HTTP, i dati che
alimentano le pagine e i file della PWA, ma **non** l'interfaccia renderizzata.
Per quella servirebbe un driver browser (es. Playwright), oggi non installato.
+115
View File
@@ -0,0 +1,115 @@
/**
* Percorsi end-to-end sull'app servita davvero: `bun test/e2e/percorsi.test.ts`.
* Verifica il giro completo route server dati risposta HTTP.
*
* L'app renderizza i contenuti dopo l'idratazione: il server invia il guscio
* (titolo, meta, splash) e il resto arriva nel browser. Qui si verifica quindi
* tutto ciò che è osservabile senza browser; per i contenuti renderizzati
* servirebbe un driver tipo Playwright.
*/
import assert from "node:assert/strict";
import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
import { avviaServer, json } from "../helpers/server";
import { prova, riepilogo } from "../helpers/prova";
const server = await avviaServer();
const url = (p: string) => `${server.baseUrl}${p}`;
async function pagina(percorso: string): Promise<string> {
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>(.*?)<\/title>/.exec(html)?.[1] ?? "";
console.log(`percorsi end-to-end su ${server.baseUrl}`);
try {
// --- 1. Primo avvio: l'app si carica --------------------------------------
await prova("la home serve il guscio dell'app con il proprio titolo", async () => {
const html = await pagina("/");
assert.match(titolo(html), /CrAPP/, "titolo della home");
assert.match(html, /icon-192\.png/, "splash di caricamento");
assert.match(html, /manifest\.webmanifest/, "manifest collegato: installabile come PWA");
assert.match(html, /<meta name="description"/, "descrizione per la condivisione");
});
// --- 2. Ogni schermata ha la sua identità ----------------------------------
await prova("ogni schermata risponde con il proprio titolo", async () => {
const attesi: Array<[string, RegExp]> = [
["/benvenuto", /Benvenuto/],
["/squadra", /Squadra/],
["/classifica", /Classifica/],
["/calendario", /Calendario|CrAPP/],
["/profilo", /Profilo|CrAPP/],
["/eventi", /Eventi|CrAPP/],
["/scout", /Scout|CrAPP/],
["/admin", /Dashboard|CrAPP/],
];
for (const [percorso, atteso] of attesi) {
assert.match(titolo(await pagina(percorso)), atteso, `${percorso}: titolo corretto`);
}
});
// --- 3. Campionato: dal portale CSI fino alla pagina -----------------------
await prova("i dati ufficiali CSI arrivano fino alla pagina Campionato", async () => {
const res = await fetch(url("/api/public/csi"));
assert.equal(res.status, 200, "l'endpoint che alimenta la pagina risponde");
const csi = (await json(res)) as DatiCsi;
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "la nostra squadra arriva dal portale");
assert.ok(
csi.partite.some((p) => p.setNostri !== null),
"ci sono risultati giocati",
);
const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/);
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
});
// --- 4. Dettaglio di un evento --------------------------------------------
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) {
const res = await fetch(url(percorso));
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
}
});
// --- 5. Installazione come PWA e notifiche push ---------------------------
await prova("i file necessari alla PWA sono serviti", async () => {
const manifest = await fetch(url("/manifest.webmanifest"));
assert.equal(manifest.status, 200);
const dati = (await json(manifest)) as { name?: string; icons?: unknown[]; start_url?: string };
assert.ok(dati.name?.includes("CrAPP"));
assert.equal(dati.start_url, "/");
assert.ok((dati.icons ?? []).length >= 2, "icone per l'installazione");
for (const file of ["/push-sw.js", "/icon-192.png", "/icon-512.png", "/robots.txt"]) {
assert.equal((await fetch(url(file))).status, 200, `${file} raggiungibile`);
}
});
await prova("il service worker e la sua configurazione si parlano", async () => {
const sw = await (await fetch(url("/push-sw.js"))).text();
assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker");
assert.match(sw, /showNotification/, "il worker mostra la notifica");
const config = await fetch(url("/api/public/push-config"));
assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile");
});
// --- 6. Percorsi inesistenti ----------------------------------------------
await prova("una rotta inesistente risponde 404 senza rompere l'app", async () => {
const res = await fetch(url("/pagina-che-non-esiste"));
assert.equal(res.status, 404);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
});
riepilogo("percorsi end-to-end");
} finally {
server.stop();
}
+32
View File
@@ -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);
}
+111
View File
@@ -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)}`);
}
}
+153
View File
@@ -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();
}
+140
View File
@@ -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 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");
}
}
+45
View File
@@ -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 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);
+62
View File
@@ -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");
+166
View File
@@ -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");
+55
View File
@@ -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");
+66
View File
@@ -0,0 +1,66 @@
/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */
import assert from "node:assert/strict";
import { classifica, formatData, giocatori, statoMeta, storicoMatch } from "@/lib/crapp-data";
// --- rosa --------------------------------------------------------------------
assert.ok(giocatori.length > 0, "la rosa non è vuota");
assert.deepEqual(
giocatori.map((g) => g.id),
giocatori.map((_, i) => `g${i + 1}`),
"gli id sono progressivi g1..gN: le tabelle del database vi si appoggiano",
);
assert.equal(
new Set(giocatori.map((g) => g.numero)).size,
giocatori.length,
"numeri di maglia unici",
);
for (const g of giocatori) {
assert.ok(g.nome.trim().length > 0 && g.ruolo.trim().length > 0, `${g.id}: nome e ruolo`);
assert.match(g.nascita, /^\d{4}-\d{2}-\d{2}$/, `${g.id}: data di nascita valida`);
assert.ok(g.numero > 0, `${g.id}: numero di maglia positivo`);
assert.equal(g.iniziali.length, 2, `${g.id}: due iniziali`);
assert.equal(g.iniziali, g.iniziali.toUpperCase(), `${g.id}: iniziali maiuscole`);
assert.ok(g.mediaVoto >= 0 && g.mediaVoto <= 10, `${g.id}: media voto nel range 1-10`);
assert.ok(g.presenze <= g.totaliEventi, `${g.id}: presenze mai oltre gli eventi totali`);
}
// --- formatData --------------------------------------------------------------
assert.equal(formatData("2026-09-01"), "mar 01 settembre");
assert.equal(formatData("2026-01-31"), "sab 31 gennaio");
assert.ok(
!formatData("2026-03-29").includes("28"),
"cambio ora legale: la data non slitta al giorno prima",
);
// --- stati presenza ----------------------------------------------------------
assert.deepEqual(Object.keys(statoMeta), [
"presente",
"assente",
"forse",
"ritardo",
"infortunato",
]);
for (const [stato, meta] of Object.entries(statoMeta)) {
assert.ok(meta.label && meta.emoji && meta.className, `${stato}: metadati completi`);
}
// --- dati demo residui -------------------------------------------------------
assert.equal(new Set(storicoMatch.map((m) => m.id)).size, storicoMatch.length, "id partite unici");
for (const m of storicoMatch) {
assert.equal(
m.parziali.length,
m.setNostri + m.setLoro,
`${m.id}: un parziale per ogni set giocato`,
);
assert.ok(m.setNostri === 3 || m.setLoro === 3, `${m.id}: una partita finisce a 3 set vinti`);
}
assert.deepEqual(
classifica.map((r) => r.pos),
classifica.map((_, i) => i + 1),
"la classifica di partenza è numerata in ordine",
);
for (const r of classifica) {
assert.equal(r.vinte + r.perse, r.giocate, `${r.squadra}: vinte + perse = giocate`);
}
console.log("crapp-data: ok");
+113
View File
@@ -0,0 +1,113 @@
/**
* Check del parsing CSI: `bun src/lib/csi-core.test.ts`.
* Con CSI_LIVE=1 verifica anche gli endpoint reali del portale.
*/
import assert from "node:assert/strict";
import {
isNostraSquadra,
parseClassifica,
partiteDaEventi,
partiteGiocate,
urlClassifica,
urlPartite,
} from "@/lib/csi-core";
const htmlClassifica = `
<table class="ranking">
<tr><th>Pos</th><th>Squadra</th></tr>
<tr><td>1</td><td><a href="team_details.php?team_id=1">Altra Squadra Girone A</a></td>
<td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>42</td><td>18</td><td>24</td></tr>
</table>
<table class="ranking">
<tr><th>Pos</th><th>Squadra</th></tr>
<tr><td>1</td><td><a href="team_details.php?team_id=9">Paolo Poggi Volley &amp; C.</a></td>
<td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>43</td><td>17</td><td>26</td></tr>
<tr class="align-middle">
<!-- Posizione --><td>4</td>
<td class="d-flex"><img src="images/teams/logos/CRAP_Volley_3359.jpg" alt="">
<a href="team_details.php?team_id=3359">C.R.A.P. Volley </a></td>
<td><b>30</b></td><td>16</td><td>10</td><td>6</td><td>3</td><td>3</td><td>36</td><td>28</td><td>8</td>
<td>1427</td><td>1329</td><td>98</td><td>0.56</td><td>1.07</td>
<td><svg viewBox="0 0 16 16"><path d="M16 8A8"/></svg></td>
</tr>
</table>`;
const righe = parseClassifica(htmlClassifica);
assert.equal(righe.length, 2, "prende solo il girone che contiene la nostra squadra");
assert.deepEqual(righe[1], {
pos: 4,
squadra: "C.R.A.P. Volley",
punti: 30,
giocate: 16,
vinte: 10,
perse: 6,
setFatti: 36,
setSubiti: 28,
});
assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML");
assert.deepEqual(parseClassifica("<p>pagina cambiata</p>"), [], "portale cambiato: nessun crash");
assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley "));
assert.ok(!isNostraSquadra("CRAP Volley B"));
const eventi = [
{
id: 1,
start: "2025-11-12T22:00:00",
team1: "C.R.A.P. Volley",
team2: "AMCM",
result: "3 - 1",
partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>",
field: "Sabin Piccolo",
project: "PVM - Campionato Open Misto Eccellenza",
},
{
id: 2,
start: "2025-11-19T22:00:00",
team1: "Gnu Tim SdC",
team2: "C.R.A.P. Volley",
result: "3 - 2",
partials: "25 - 21</br>18 - 25</br>",
field: "BVI",
project: "PVM - Campionato Open Misto Eccellenza",
},
{ id: 3, start: "2026-09-10T21:00:00", team1: "C.R.A.P. Volley", team2: "Esselle", result: "" },
{ id: 4, start: "2025-10-01T21:00:00", team1: "Esselle", team2: "AMCM", result: "3 - 0" },
];
const partite = partiteDaEventi(eventi);
assert.equal(partite.length, 3, "scarta le gare di altre squadre");
assert.equal(partite[0]!.data, "2026-09-10", "ordinate dalla più recente");
assert.equal(partite[0]!.setNostri, null, "gara futura senza risultato");
const casa = partite.find((p) => p.id === "1")!;
assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]);
assert.deepEqual(casa.parziali[1], [23, 25]);
assert.equal(casa.ora, "22:00");
const trasferta = partite.find((p) => p.id === "2")!;
assert.deepEqual(
[trasferta.casa, trasferta.avversario, trasferta.setNostri, trasferta.setLoro],
[false, "Gnu Tim SdC", 2, 3],
"in trasferta il punteggio va ribaltato",
);
assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali");
assert.equal(partiteGiocate(partite).length, 2);
assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash");
if (process.env["CSI_LIVE"]) {
const [html, json] = await Promise.all([
fetch(urlClassifica()).then((r) => r.text()),
fetch(urlPartite()).then((r) => r.json()),
]);
const live = parseClassifica(html);
const nostra = live.find((r) => isNostraSquadra(r.squadra));
assert.ok(nostra, "la nostra squadra è in classifica");
assert.ok(live.length >= 8 && nostra.giocate > 0);
const livePartite = partiteDaEventi(json);
assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0);
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`);
}
console.log("csi-core: ok");
+64
View File
@@ -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");
+33
View File
@@ -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");
+53
View File
@@ -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");
+106
View File
@@ -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");
+147
View File
@@ -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");
+64
View File
@@ -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");
+98
View File
@@ -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");
+190
View File
@@ -0,0 +1,190 @@
/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */
import assert from "node:assert/strict";
import {
aRigaProfilo,
completamento,
csvTesseramento,
daRigaProfilo,
sezioniComplete,
statoScadenza,
type Profilo,
} from "@/lib/profili-core";
import {
dividiNome,
numeroGiaUsato,
rosaFallback,
slotDi,
slotLiberi,
validaDatiSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
const vuoto: Profilo = {
giocatoreId: "g1",
dataNascita: null,
luogoNascita: null,
indirizzo: null,
telefono: null,
email: null,
documentoTipo: null,
documentoNumero: null,
documentoRilasciatoDa: null,
documentoEmissione: null,
documentoScadenza: null,
documentoFrontePath: null,
documentoRetroPath: null,
certificatoScadenza: null,
certificatoPath: null,
fotoPath: null,
};
const completo: Profilo = {
...vuoto,
dataNascita: "1995-05-01",
luogoNascita: "Bologna",
indirizzo: "Via Roma 1",
telefono: "3331234567",
email: "ivan@example.com",
documentoTipo: "Carta d'identità",
documentoNumero: "CA12345",
documentoRilasciatoDa: "Comune di Bologna",
documentoEmissione: "2020-01-01",
documentoScadenza: "2030-01-01",
documentoFrontePath: "g1/documento-fronte.jpg",
documentoRetroPath: "g1/documento-retro.jpg",
certificatoScadenza: "2027-06-30",
certificatoPath: "g1/certificato.pdf",
fotoPath: "g1/foto.jpg",
};
// --- completamento -----------------------------------------------------------
assert.equal(completamento(null), 0, "profilo inesistente = 0%");
assert.equal(completamento(vuoto), 0);
assert.equal(completamento(completo), 100, "tutte le sezioni piene = 100%");
assert.equal(completamento({ ...completo, fotoPath: null }), 90, "la foto pesa 10");
assert.equal(completamento({ ...completo, certificatoPath: null }), 70, "il certificato pesa 30");
assert.equal(
completamento({ ...completo, email: null }),
70,
"i dati personali sono completi solo tutti insieme",
);
// I metadati senza file (o viceversa) non contano come sezione completa.
assert.equal(sezioniComplete({ ...completo, certificatoScadenza: null }).certificato, false);
assert.equal(
sezioniComplete({ ...completo, documentoRetroPath: null }).documento,
false,
"il documento vale solo con fronte e retro",
);
assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false);
// --- aRigaProfilo ------------------------------------------------------------
const riga = aRigaProfilo({ ...completo, luogoNascita: " ", telefono: " 333 " });
assert.equal(riga.luogo_nascita, null, "i campi solo-spazi tornano NULL, non stringa vuota");
assert.equal(riga.telefono, "333", "il resto viene ripulito ai bordi");
assert.equal(riga.documento_fronte_path, "g1/documento-fronte.jpg");
assert.deepEqual(
daRigaProfilo(aRigaProfilo(completo)),
completo,
"modello -> riga -> modello non perde niente",
);
// --- statoScadenza -----------------------------------------------------------
const oggi = "2026-08-30";
assert.equal(statoScadenza(null, null, oggi), "mancante");
assert.equal(statoScadenza("2027-01-01", null, oggi), "mancante", "senza file non vale");
assert.equal(statoScadenza(null, "g1/cert.pdf", oggi), "mancante", "senza data non vale");
assert.equal(statoScadenza("2026-08-29", "g1/cert.pdf", oggi), "scaduto");
assert.equal(
statoScadenza("2026-08-30", "g1/cert.pdf", oggi),
"valido",
"scade oggi = ancora valido",
);
assert.equal(statoScadenza("2026-12-31", "g1/cert.pdf", oggi), "valido");
// --- csvTesseramento ---------------------------------------------------------
const squadra: GiocatoreSquadra[] = [
{
id: "g1",
nome: "Ivan",
cognome: "Cacciari",
numero: 23,
ruolo: "Banda",
authUserId: null,
attivo: true,
},
{
id: "g2",
nome: "Anna",
cognome: 'De "Rossi"',
numero: 7,
ruolo: "Libero",
authUserId: "u2",
attivo: true,
},
];
const csv = csvTesseramento(squadra, { g1: completo });
const righe = csv.split("\n");
assert.equal(righe.length, 3, "intestazione + un giocatore per riga");
assert.equal(righe[0]?.split(";").length, 12, "i 12 campi richiesti dal CSI");
assert.match(righe[1] ?? "", /^Ivan;Cacciari;1995-05-01;Bologna/);
assert.match(
righe[2] ?? "",
/^Anna;"De ""Rossi""";;;/,
"chi non ha profilo esce con i campi vuoti",
);
// --- anagrafica squadra ------------------------------------------------------
assert.deepEqual(dividiNome("Ivan Cacciari"), { nome: "Ivan", cognome: "Cacciari" });
assert.deepEqual(
dividiNome("Carlo Di Castelnuovo"),
{ nome: "Carlo", cognome: "Di Castelnuovo" },
"il cognome composto resta intero",
);
assert.deepEqual(dividiNome("Ivan"), { nome: "Ivan", cognome: "" });
assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot");
assert.equal(slotDi(squadra, "u2")?.id, "g2");
assert.equal(slotDi(squadra, "sconosciuto"), null);
assert.deepEqual(
slotLiberi(squadra).map((g) => g.id),
["g1"],
"uno slot già collegato non è più libero",
);
assert.deepEqual(
slotLiberi([...squadra, { ...squadra[0]!, id: "g3", attivo: false }]).map((g) => g.id),
["g1"],
"i giocatori non attivi restano fuori",
);
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" };
assert.equal(validaDatiSquadra(datiOk), null);
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
assert.match(validaDatiSquadra({ ...datiOk, ruolo: " " }) ?? "", /ruolo/i);
assert.match(
validaDatiSquadra({ ...datiOk, numero: 0 }) ?? "",
/numero/i,
"il database rifiuta numero <= 0: meglio dirlo prima",
);
assert.match(validaDatiSquadra({ ...datiOk, numero: -3 }) ?? "", /numero/i);
assert.match(validaDatiSquadra({ ...datiOk, numero: 1.5 }) ?? "", /numero/i);
assert.equal(numeroGiaUsato(squadra, "g1", 7), true, "il 7 è di g2");
assert.equal(numeroGiaUsato(squadra, "g2", 7), false, "il proprio numero non è un conflitto");
assert.equal(numeroGiaUsato(squadra, "g1", 99), false);
assert.equal(
numeroGiaUsato([{ ...squadra[1]!, attivo: false }], "g1", 7),
false,
"chi non è più in rosa non blocca il numero",
);
const fallback = rosaFallback();
assert.ok(fallback.length > 0, "il fallback da crapp-data non è mai vuoto");
assert.ok(
fallback.every((g) => g.authUserId === null && g.attivo),
"il fallback non può collegare account",
);
console.log("profili-core: ok");
+81
View File
@@ -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");
+58
View File
@@ -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");
+120
View File
@@ -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");
+60
View File
@@ -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");
+1 -1
View File
@@ -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",