Merge branch 'develop' into main
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"vercel@claude-plugins-official": true,
|
||||
"supabase@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -39,3 +39,5 @@ dist-ssr
|
||||
|
||||
# Optional
|
||||
.vercel
|
||||
# Supabase CLI local state
|
||||
supabase/.temp/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -1,118 +1,95 @@
|
||||
# CRAP Volley Hub
|
||||
# CrAPP 🏐
|
||||
|
||||
CrAPP – App per CRAP Volley
|
||||
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
|
||||
|
||||
Vorrei sviluppare un’app mobile per la squadra di pallavolo CRAP Volley, con nome CrAPP, disponibile per Android e iOS. L’obiettivo è creare un’app semplice da usare, moderna, bella da vedere e più coinvolgente rispetto a SportEasy, includendo anche funzionalità normalmente a pagamento in altre app.
|
||||
## Funzionalità principali
|
||||
|
||||
Funzionalità principali
|
||||
- Gestione squadra
|
||||
- Gestione presenze
|
||||
- Calendario allenamenti e partite
|
||||
- Scout Live
|
||||
- Badge e gamification
|
||||
- Statistiche
|
||||
- Notifiche intelligenti
|
||||
- Gestione amministrativa
|
||||
- AI per la pianificazione degli allenamenti (in sviluppo)
|
||||
|
||||
Gestione presenze/assenze
|
||||
---
|
||||
|
||||
Partite
|
||||
## Stack tecnologico
|
||||
|
||||
Allenamenti
|
||||
- React 19
|
||||
- TypeScript
|
||||
- TanStack Start
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- Supabase
|
||||
- GitHub
|
||||
- Vercel
|
||||
|
||||
Eventi extra
|
||||
---
|
||||
|
||||
Stati rapidi: presente, assente, forse, in ritardo, indisponibile, infortunato
|
||||
## Ambienti
|
||||
|
||||
Statistiche giocatori
|
||||
- `main` → Produzione
|
||||
- `develop` → Sviluppo
|
||||
|
||||
Presenze totali
|
||||
---
|
||||
|
||||
Presenze consecutive
|
||||
## Avvio locale
|
||||
|
||||
Gol/punti o altre statistiche specifiche della pallavolo
|
||||
|
||||
MVP, migliori performance, medie stagione
|
||||
|
||||
Statistiche partite
|
||||
|
||||
Risultati
|
||||
|
||||
Formazioni
|
||||
|
||||
Andamento set
|
||||
|
||||
Storico match
|
||||
|
||||
Campionato in tempo reale
|
||||
|
||||
Visualizzazione classifica e risultati
|
||||
|
||||
Dati presi direttamente dal sito del CSI
|
||||
|
||||
Aggiornamento automatico o importazione periodica
|
||||
|
||||
Calendario squadra
|
||||
|
||||
Allenamenti
|
||||
|
||||
Partite
|
||||
|
||||
Promemoria
|
||||
|
||||
Vista mensile e lista eventi
|
||||
|
||||
Profilo giocatore
|
||||
|
||||
Foto
|
||||
|
||||
Ruolo
|
||||
|
||||
Statistiche personali
|
||||
|
||||
Badge e obiettivi
|
||||
|
||||
Idea di stile
|
||||
|
||||
Interfaccia sportiva, pulita e moderna
|
||||
|
||||
Molto mobile-first
|
||||
|
||||
Design divertente, energico e più “premium”
|
||||
|
||||
Inserire in seguito il logo della squadra
|
||||
|
||||
Possibile uso di badge, livelli, premi e mini-gamification per rendere l’app più piacevole da usare
|
||||
|
||||
Extra che sarebbe bello aggiungere
|
||||
|
||||
Notifiche push per convocazioni e cambi orario
|
||||
|
||||
Chat o bacheca squadra
|
||||
|
||||
Report automatici dopo le partite
|
||||
|
||||
Sondaggi rapidi per disponibilità
|
||||
|
||||
Sezione “Best of the match”
|
||||
|
||||
Obiettivi di gruppo per presenza e continuità
|
||||
|
||||
Obiettivo finale
|
||||
|
||||
Realizzare una app che non sia solo utile per la gestione della squadra, ma anche piacevole, coinvolgente e bella da usare ogni giorno.
|
||||
|
||||
This project was built with [Lovable](https://lovable.dev).
|
||||
|
||||
**Live app**: https://volley-cronos-app.lovable.app
|
||||
|
||||
## Build with Lovable
|
||||
|
||||
Continue developing this project in the [Lovable editor](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c).
|
||||
|
||||
- **Ship faster**: describe what you want to build and Lovable handles the code.
|
||||
- **Stay in sync**: every change made in Lovable is committed straight to this repository.
|
||||
- **Full ownership**: this code is yours. Push to `main` on GitHub and your changes sync back into Lovable, ready for your next prompt.
|
||||
|
||||
## Development
|
||||
|
||||
Prefer working locally? You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating).
|
||||
|
||||
```sh
|
||||
git clone <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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,517 @@
|
||||
# Registro delle decisioni di progetto
|
||||
|
||||
Questo documento raccoglie le **decisioni importanti** prese nel corso della vita di CrAPP: scelte che hanno influito sulla direzione del prodotto, sull’organizzazione del lavoro o su come l’app si evolve nel tempo.
|
||||
|
||||
Non descrive *come* è fatto il codice. Per quello esistono `ARCHITECTURE.md` e `DATABASE.md`.
|
||||
|
||||
Serve a rispondere a domande del tipo:
|
||||
|
||||
- *Perché abbiamo scelto così?*
|
||||
- *Cosa avevamo escluso e perché?*
|
||||
- *Quando conviene riaprire una decisione?*
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
|---|---|
|
||||
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
|
||||
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
|
||||
| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop |
|
||||
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
|
||||
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
||||
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
||||
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
||||
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
|
||||
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
|
||||
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
||||
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
|
||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
|
||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
| ID | Titolo |
|
||||
|---|---|
|
||||
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
|
||||
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
||||
|
||||
---
|
||||
|
||||
## Come usare questo registro
|
||||
|
||||
Ogni decisione segue lo stesso schema:
|
||||
|
||||
| Campo | Significato |
|
||||
|---|---|
|
||||
| **Data** | Quando la decisione è stata presa o confermata |
|
||||
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
|
||||
| **Contesto** | Quale problema o opportunità avevamo di fronte |
|
||||
| **Decisione** | Cosa abbiamo scelto di fare |
|
||||
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
|
||||
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
|
||||
| **Riesame** | Quando ha senso riconsiderarla |
|
||||
|
||||
**Quando aggiungere una voce**
|
||||
|
||||
- una scelta influisce su più moduli o su più release;
|
||||
- escludiamo un’alternativa non ovvia;
|
||||
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
|
||||
- cambiamo una decisione precedente.
|
||||
|
||||
**Quando non serve**
|
||||
|
||||
- dettagli implementativi locali;
|
||||
- scelte estetiche minori;
|
||||
- bugfix o correzioni puntuali.
|
||||
|
||||
**Come registrare una nuova decisione**
|
||||
|
||||
Copiare [`_template-dd.md`](_template-dd.md) in fondo al documento, assegnare il primo ID
|
||||
libero e aggiungerlo all'indice.
|
||||
|
||||
---
|
||||
|
||||
## Decisioni accettate
|
||||
|
||||
---
|
||||
|
||||
### DD-001 — CrAPP deve restare indipendente da Lovable
|
||||
|
||||
**Data:** luglio 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi.
|
||||
|
||||
**Decisione**
|
||||
Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team.
|
||||
|
||||
**Alternative scartate**
|
||||
- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno.
|
||||
- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già.
|
||||
|
||||
**Conseguenze**
|
||||
- Maggiore libertà e responsabilità per il team.
|
||||
- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco.
|
||||
- L’app deve poter girare anche fuori dall’ecosistema Lovable (vedi `PORTABILITA.md`).
|
||||
|
||||
**Riesame**
|
||||
Quando il progetto non userà più alcun componente Lovable.
|
||||
|
||||
---
|
||||
|
||||
### DD-002 — Sviluppo document-first
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Con più persone (e assistenti AI) che lavorano sul codice, serviva un modo per evitare funzionalità “inventate” al volo e incoerenze tra moduli.
|
||||
|
||||
**Decisione**
|
||||
Ogni nuova funzionalità significativa viene prima **progettata e documentata** in `docs/modules/`, poi implementata. Il flusso ufficiale è: idea → progettazione → documentazione → database → codice → test → release.
|
||||
|
||||
**Alternative scartate**
|
||||
- Documentare solo a posteriori → troppo spesso incompleto o assente.
|
||||
- Affidarsi solo al codice come documentazione → illeggibile per chi non programma.
|
||||
|
||||
**Conseguenze**
|
||||
- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite.
|
||||
- I moduli v1.0 vanno retro-documentati quando possibile.
|
||||
- Nessuna feature non documentata entra in produzione.
|
||||
|
||||
**Riesame**
|
||||
Se il team diventa molto piccolo e la documentazione smette di essere consultata.
|
||||
|
||||
---
|
||||
|
||||
### DD-003 — Due branch: main stabile, develop per il lavoro
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Serve separare ciò che i giocatori usano ogni giorno da ciò che è ancora in prova.
|
||||
|
||||
**Decisione**
|
||||
- `main` → produzione, sempre funzionante, deploy automatico.
|
||||
- `develop` → sviluppo e preview, merge su `main` solo dopo test.
|
||||
|
||||
**Alternative scartate**
|
||||
- Sviluppare direttamente su `main` → rischio di rotture in produzione.
|
||||
- Branch per ogni feature → eccessivo per la dimensione attuale del team.
|
||||
|
||||
**Conseguenze**
|
||||
- Gli utenti in produzione non vedono lavori incompleti.
|
||||
- Ogni release su `main` deve includere verifica delle funzionalità esistenti.
|
||||
|
||||
**Riesame**
|
||||
Se il team cresce e servono review più granulari (pull request per feature).
|
||||
|
||||
---
|
||||
|
||||
### DD-004 — Ogni versione aggiunge, non riscrive
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
CrAPP v1.0 è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
|
||||
|
||||
**Decisione**
|
||||
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
|
||||
|
||||
**Alternative scartate**
|
||||
- Refactoring ampio “per pulire” insieme a ogni release → alto rischio, poco valore immediato per gli utenti.
|
||||
|
||||
**Conseguenze**
|
||||
- Coesistono temporaneamente soluzioni vecchie e nuove (es. dati hardcoded accanto a tabelle database).
|
||||
- Il debito tecnico va gestito con migration dedicate, non di nascosto.
|
||||
|
||||
**Riesame**
|
||||
Quando un modulo diventa ingestibile o blocca una release importante.
|
||||
|
||||
---
|
||||
|
||||
### DD-005 — Mobile-first, pochi click, pochi schermi
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I giocatori usano l’app soprattutto da smartphone, spesso in spogliatoio o in palestra, con poco tempo e poca pazienza.
|
||||
|
||||
**Decisione**
|
||||
Interfaccia semplice, veloce, ottimizzata per telefono. Navigazione ridotta (barra inferiore). Ogni schermata deve avere uno scopo chiaro.
|
||||
|
||||
**Alternative scartate**
|
||||
- Layout da desktop con menu complessi → scomodo in mobilità.
|
||||
- App nativa iOS/Android → costi e tempi di pubblicazione non giustificati per una squadra amatoriale.
|
||||
|
||||
**Conseguenze**
|
||||
- Funzionalità amministrative complesse vanno semplificate o suddivise con cura.
|
||||
- La PWA è la forma giusta per questo pubblico.
|
||||
|
||||
**Riesame**
|
||||
Se emergono esigenze desktop forti (es. gestione documenti massiva solo da PC).
|
||||
|
||||
---
|
||||
|
||||
### DD-006 — Intelligenza artificiale solo se porta beneficio reale
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
L’AI è attraente ma può complicare l’app, aumentare i costi e creare aspettative irrealistiche.
|
||||
|
||||
**Decisione**
|
||||
Usare l’AI solo quando riduce lavoro agli admin o migliora concretamente l’esperienza dei giocatori. Non introdurla “perché si può”.
|
||||
|
||||
**Alternative scartate**
|
||||
- AI ovunque (chatbot, suggerimenti automatici, analisi predittive) → fuori focus per una squadra amatoriale.
|
||||
|
||||
**Conseguenze**
|
||||
- “AI Allenamenti” è in roadmap v1.2, non v1.1.
|
||||
- Ogni proposta AI va valutata con la domanda: *chi risparmia tempo e quanto?*
|
||||
|
||||
**Riesame**
|
||||
Quando l’AI diventa economica e affidabile per casi d’uso chiari (es. generazione allenamenti).
|
||||
|
||||
---
|
||||
|
||||
### DD-007 — Badge calcolati dall’app, non salvati nel database
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I badge dipendono da statistiche già disponibili (presenze, MVP, cacche, ecc.). Salvare ogni badge sbloccato nel database aggiungerebbe complessità senza beneficio immediato.
|
||||
|
||||
**Decisione**
|
||||
I badge vengono **calcolati al volo** dall’applicazione in base ai dati esistenti. Non esiste una tabella badge dedicata.
|
||||
|
||||
**Alternative scartate**
|
||||
- Tabella `badge_sbloccati` con storico → utile in futuro per notifiche retroattive o audit, ma non necessaria ora.
|
||||
|
||||
**Conseguenze**
|
||||
- Meno migration e meno sincronizzazione.
|
||||
- Lo “sblocco” celebrativo usa cache locale per non ripetere animazioni.
|
||||
- Un eventuale storico badge richiederà una nuova decisione.
|
||||
|
||||
**Riesame**
|
||||
Se servono badge manuali assegnati dagli admin o storico immutabile.
|
||||
|
||||
---
|
||||
|
||||
### DD-008 — Gamification equa tra ruoli
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
In pallavolo i ruoli hanno statistiche diverse (un libero non segna punti d’attacco). Confrontare tutti sugli stessi numeri sarebbe ingiusto e scoraggiante.
|
||||
|
||||
**Decisione**
|
||||
Le statistiche **personali** in profilo e squadra devono essere **eque per tutti i ruoli**. Dati tecnici di reparto (punti, ace, muri) restano nello Scout Live come informazione di squadra, non come leva competitiva individuale.
|
||||
|
||||
**Alternative scartate**
|
||||
- Classifiche individuali basate su punti → penalizza libero, palleggiatore, centrale.
|
||||
|
||||
**Conseguenze**
|
||||
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
|
||||
- Lo scout resta strumento tecnico, non gioco.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
|
||||
|
||||
---
|
||||
|
||||
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
|
||||
|
||||
**Decisione**
|
||||
- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
|
||||
- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
|
||||
|
||||
**Alternative scartate**
|
||||
- Integrazione CSI già in v1.1 → scope troppo ampio, dipendenza da API esterne non controllate.
|
||||
|
||||
**Conseguenze**
|
||||
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
|
||||
- L’export CSV deve essere affidabile e completo: è il deliverable chiave della v1.1.
|
||||
|
||||
**Riesame**
|
||||
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
|
||||
|
||||
---
|
||||
|
||||
### DD-010 — Profilo giocatore: niente storico certificati in v1
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
|
||||
|
||||
**Decisione**
|
||||
In v1 il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
|
||||
|
||||
**Alternative scartate**
|
||||
- Archivio certificati → utile per audit, rinviato a versioni future.
|
||||
|
||||
**Conseguenze**
|
||||
- Implementazione più semplice e veloce.
|
||||
- Gli admin vedono solo il certificato attuale.
|
||||
- Va comunicato chiaramente ai giocatori che sostituire il file elimina quello precedente.
|
||||
|
||||
**Riesame**
|
||||
Se il CSI o il regolamento interno richiedono conservazione storica.
|
||||
|
||||
---
|
||||
|
||||
### DD-011 — Autenticazione reale prima del profilo amministrativo completo
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Oggi l’app identifica l’utente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere *chi* sta operando e impedire accessi non autorizzati.
|
||||
|
||||
**Decisione**
|
||||
Prima di completare il modulo Profilo Giocatore (v1.1), introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
- Continuare solo con selezione da lista → inaccettabile per dati sensibili.
|
||||
- Lovable Auth → crea dipendenza da piattaforma che stiamo abbandonando.
|
||||
|
||||
**Conseguenze**
|
||||
- Tutti dovranno fare login almeno una volta.
|
||||
- Gli admin useranno ruoli veri (`user_roles`), non una lista di nomi hardcoded.
|
||||
- È prerequisito per dashboard admin e export CSI.
|
||||
|
||||
**Riesame**
|
||||
Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali).
|
||||
|
||||
---
|
||||
|
||||
### DD-012 — Non migrare gli ID giocatore in v1.1
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
L’app usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
|
||||
|
||||
**Decisione**
|
||||
Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
|
||||
|
||||
**Alternative scartate**
|
||||
- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche.
|
||||
|
||||
**Conseguenze**
|
||||
- Coesistono due modelli anagrafici fino a migration dedicata.
|
||||
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
|
||||
|
||||
**Riesame**
|
||||
Quando la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa.
|
||||
|
||||
---
|
||||
|
||||
### DD-013 — Portabilità: l’app non deve dipendere da servizi esclusivi
|
||||
|
||||
**Data:** luglio 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro.
|
||||
|
||||
**Decisione**
|
||||
CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti.
|
||||
|
||||
**Alternative scartate**
|
||||
- Accettare lock-in per velocità → contrario alla lunga vita del progetto.
|
||||
|
||||
**Conseguenze**
|
||||
- Supabase va bene perché è PostgreSQL e self-hostable.
|
||||
- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler.
|
||||
|
||||
**Riesame**
|
||||
Se si adotta un servizio che viola questa regola.
|
||||
|
||||
---
|
||||
|
||||
### DD-016 — Schema dati Profilo Giocatore v1.1 (F0)
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con l’anagrafica attuale (`g1`…`g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare l’autenticazione e come proteggere documenti sensibili — senza toccare le tabelle v1.0 già operative.
|
||||
|
||||
**Decisione**
|
||||
Per la v1.1 si introducono **due nuove tabelle additive**:
|
||||
|
||||
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
|
||||
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
|
||||
|
||||
Regole vincolanti:
|
||||
|
||||
1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
|
||||
2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
|
||||
3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
|
||||
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
|
||||
5. Le **tabelle v1.0 esistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID in v1.1 (coerente con DD-012).
|
||||
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo in v1.1.
|
||||
|
||||
**Alternative scartate**
|
||||
- Estendere la tabella `giocatori` UUID → conflitto con ID operativi del codice e rischio di regressioni.
|
||||
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
|
||||
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti d’identità.
|
||||
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
|
||||
- Modificare tabelle v1.0 per aggiungere FK verso il profilo → viola DD-004 e DD-012.
|
||||
|
||||
**Conseguenze**
|
||||
- Coesistono temporaneamente tre rappresentazioni dell’anagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
|
||||
- `src/lib/rosa.ts` dovrà leggere prima dal database e ricadere su `crapp-data.ts` in caso di errore o assenza dati.
|
||||
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
|
||||
- Lo storico certificati non viene conservato in v1 (coerente con DD-010).
|
||||
- Le migration M1–M3 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
|
||||
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
|
||||
|
||||
**Riesame**
|
||||
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
|
||||
- Quando si pianifica la convergenza verso UUID (DD-012, post v1.1).
|
||||
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
|
||||
|
||||
---
|
||||
|
||||
### DD-017 — L'amministratore può compilare i dati al posto del giocatore
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
|
||||
|
||||
**Decisione**
|
||||
Dalla dashboard amministratore, un admin può:
|
||||
|
||||
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
|
||||
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
|
||||
3. **scollegare** un account da un profilo, liberando lo slot.
|
||||
|
||||
Restano fuori, e non cambiano:
|
||||
|
||||
- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
|
||||
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
|
||||
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
|
||||
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
|
||||
|
||||
**Conseguenze**
|
||||
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
|
||||
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
|
||||
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
|
||||
- Il completamento del profilo smette di essere un indicatore di *chi ha risposto* e diventa un indicatore di *quali dati mancano*, chiunque li abbia inseriti.
|
||||
|
||||
**Riesame**
|
||||
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
|
||||
- Se serve tracciare *chi* ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
|
||||
|
||||
---
|
||||
|
||||
## Decisioni in valutazione
|
||||
|
||||
---
|
||||
|
||||
### DD-014 — Convergenza schema database (eventi e presenze)
|
||||
|
||||
**Data:** —
|
||||
**Stato:** In valutazione
|
||||
|
||||
**Contesto**
|
||||
Esistono due modelli paralleli: tabelle “legacy” usate dall’app (`eventi_app`, `risposte_presenze`) e tabelle “nuove” con autenticazione e vincoli (`eventi`, `presenze`, `giocatori` UUID).
|
||||
|
||||
**Decisione proposta**
|
||||
Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
|
||||
|
||||
**Perché non ora**
|
||||
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
|
||||
|
||||
**Riesame previsto**
|
||||
Post v1.1, con migration e test dedicati.
|
||||
|
||||
---
|
||||
|
||||
### DD-015 — Rosa anagrafica: da codice hardcoded a database
|
||||
|
||||
**Data:** —
|
||||
**Stato:** In valutazione
|
||||
|
||||
**Contesto**
|
||||
La lista giocatori vive ancora nel codice sorgente. Il database ha già una tabella popolata ma non usata.
|
||||
|
||||
**Decisione proposta**
|
||||
Spostare l’anagrafica su database, mantenendo gli stessi ID finché non si fa DD-012.
|
||||
|
||||
**Perché non ora**
|
||||
Il profilo v1.1 può agganciarsi agli ID attuali; la migrazione rosa può essere fase 2.
|
||||
|
||||
**Riesame previsto**
|
||||
In parallelo o subito dopo il rollout auth.
|
||||
|
||||
---
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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,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,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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TeamLogo } from "../components/crapp/ui-bits";
|
||||
import { useGiocatoreBase } from "../lib/user-store";
|
||||
import { useSessione } from "../lib/auth";
|
||||
|
||||
function NotFoundComponent() {
|
||||
return (
|
||||
@@ -145,17 +146,21 @@ function RootComponent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const giocatore = useGiocatoreBase();
|
||||
const { pronta, utenteId } = useSessione();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const isBenvenuto = location.pathname === "/benvenuto";
|
||||
|
||||
// Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome
|
||||
// (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori
|
||||
// chi ha già la sessione in localStorage.
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (!giocatore && !isBenvenuto) {
|
||||
if (pronta && (!giocatore || !utenteId) && !isBenvenuto) {
|
||||
navigate({ to: "/benvenuto" });
|
||||
}
|
||||
}, [giocatore, isBenvenuto, navigate]);
|
||||
}, [giocatore, utenteId, pronta, isBenvenuto, navigate]);
|
||||
|
||||
if (!mounted) {
|
||||
if (!mounted || !pronta) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background">
|
||||
<TeamLogo className="h-16 w-16 animate-pulse" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
main
|
||||
+22
-1
@@ -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';
|
||||
@@ -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>';
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Schema e permessi del Profilo Giocatore: `bun test/integration/schema-profili.test.ts`.
|
||||
*
|
||||
* Verifica contro un database vero (locale con `npx supabase start`, oppure quello
|
||||
* configurato in `.env`) le tre cose che il codice dà per scontate: le colonne della
|
||||
* tabella, la chiusura verso l'utente anonimo e il bucket privato.
|
||||
*
|
||||
* Salta con un motivo esplicito quando mancano le credenziali o quando le migration
|
||||
* M2/M3 non sono ancora applicate a quel database: sono stati dell'ambiente, non difetti.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { COLONNE_PROFILO } from "@/lib/profili-core";
|
||||
import { envDaFile } from "../helpers/server";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const env = { ...envDaFile(), ...process.env };
|
||||
const URL_BASE = env["SUPABASE_URL"];
|
||||
const CHIAVE_SERVIZIO = env["SUPABASE_SERVICE_ROLE_KEY"];
|
||||
const CHIAVE_PUBBLICA = env["SUPABASE_PUBLISHABLE_KEY"];
|
||||
|
||||
/** Le chiavi nuove sono opache e viaggiano solo in `apikey`; quelle legacy sono JWT. */
|
||||
function intestazioni(chiave: string): Record<string, string> {
|
||||
const base: Record<string, string> = { apikey: chiave, "content-type": "application/json" };
|
||||
if (chiave.startsWith("eyJ")) base["Authorization"] = `Bearer ${chiave}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
const rest = (percorso: string, chiave: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: { ...intestazioni(chiave), ...(init?.headers ?? {}) },
|
||||
});
|
||||
|
||||
const bucketProfili = (chiave: string) =>
|
||||
fetch(`${URL_BASE}/storage/v1/bucket/profili-giocatore`, { headers: intestazioni(chiave) });
|
||||
|
||||
console.log(`schema profili su ${URL_BASE ?? "(non configurato)"}`);
|
||||
|
||||
if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
|
||||
salta("schema e permessi dei profili", "credenziali Supabase non configurate");
|
||||
riepilogo("schema profili");
|
||||
} else {
|
||||
try {
|
||||
// --- M1: anagrafica della squadra ------------------------------------------
|
||||
await prova("la rosa di M1 è popolata e collegabile agli account", async () => {
|
||||
const res = await rest(
|
||||
"giocatori_squadra?select=id,nome,cognome,auth_user_id,attivo",
|
||||
CHIAVE_SERVIZIO,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const righe = (await res.json()) as Array<{ id: string }>;
|
||||
assert.ok(righe.length >= 17, `attesi almeno 17 giocatori, trovati ${righe.length}`);
|
||||
assert.ok(
|
||||
righe.every((r) => /^g[0-9]+$/.test(r.id)),
|
||||
"gli ID restano nel formato g1..gN (DD-012)",
|
||||
);
|
||||
});
|
||||
|
||||
// Attenzione al falso verde: su un UPDATE che non tocca nessuna riga PostgREST
|
||||
// risponde comunque 2xx. Quello che conta è che il dato non cambi.
|
||||
await prova("un anonimo non si collega a uno slot della rosa", async () => {
|
||||
const res = await rest("giocatori_squadra?id=eq.g1", CHIAVE_PUBBLICA, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ auth_user_id: "00000000-0000-0000-0000-000000000000" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const aggiornate = (await res.json()) as unknown[];
|
||||
assert.equal(aggiornate.length, 0, "nessuna riga aggiornata senza sessione");
|
||||
}
|
||||
const dopo = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO);
|
||||
const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>;
|
||||
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero");
|
||||
});
|
||||
|
||||
// --- M2: tabella dei profili -----------------------------------------------
|
||||
const m2 = await rest("profili_giocatore?select=giocatore_id&limit=1", CHIAVE_SERVIZIO);
|
||||
if (!m2.ok) {
|
||||
salta("schema e permessi di profili_giocatore", "M2 non applicata (npx supabase db push)");
|
||||
} else {
|
||||
await prova("profili_giocatore espone tutte le colonne che il codice legge", async () => {
|
||||
const colonne = COLONNE_PROFILO.replace(/\s/g, "");
|
||||
const res = await rest(`profili_giocatore?select=${colonne}&limit=1`, CHIAVE_SERVIZIO);
|
||||
const corpo = await res.text();
|
||||
assert.equal(res.status, 200, corpo);
|
||||
assert.ok(Array.isArray(JSON.parse(corpo)));
|
||||
});
|
||||
|
||||
await prova("il documento ha due facciate separate", async () => {
|
||||
const res = await rest(
|
||||
"profili_giocatore?select=documento_fronte_path,documento_retro_path&limit=1",
|
||||
CHIAVE_SERVIZIO,
|
||||
);
|
||||
assert.equal(res.status, 200, "fronte e retro sono colonne distinte");
|
||||
});
|
||||
|
||||
// Il punto della RLS: senza sessione non si legge e non si scrive. È la garanzia su
|
||||
// cui si regge tutto il modulo, e a nessuno serve fidarsi della prosa.
|
||||
await prova("un anonimo non legge i profili", async () => {
|
||||
const res = await rest("profili_giocatore?select=giocatore_id", CHIAVE_PUBBLICA);
|
||||
if (res.ok) {
|
||||
const righe = (await res.json()) as unknown[];
|
||||
assert.equal(righe.length, 0, "nessuna riga visibile senza sessione");
|
||||
} else {
|
||||
assert.ok(res.status >= 400, `accesso negato (${res.status})`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("un anonimo non scrive i profili", async () => {
|
||||
const res = await rest("profili_giocatore", CHIAVE_PUBBLICA, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ giocatore_id: "g1", telefono: "000" }),
|
||||
});
|
||||
assert.ok(!res.ok, `la scrittura anonima deve fallire, invece ha risposto ${res.status}`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- M3: bucket privato ------------------------------------------------------
|
||||
const m3 = await bucketProfili(CHIAVE_SERVIZIO);
|
||||
if (!m3.ok) {
|
||||
salta("bucket dei documenti", "M3 non applicata (npx supabase db push)");
|
||||
} else {
|
||||
await prova("il bucket dei documenti è privato", async () => {
|
||||
const bucket = (await m3.json()) as { public?: boolean };
|
||||
assert.equal(bucket.public, false, "documenti e certificati non sono mai pubblici");
|
||||
});
|
||||
|
||||
await prova("un anonimo non scarica i file dei profili", async () => {
|
||||
const res = await fetch(
|
||||
`${URL_BASE}/storage/v1/object/profili-giocatore/g1/certificato.pdf`,
|
||||
{ headers: intestazioni(CHIAVE_PUBBLICA) },
|
||||
);
|
||||
assert.ok(!res.ok, `nessun accesso anonimo allo storage (${res.status})`);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
riepilogo("schema profili");
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Esecutore della suite: `bun test/run.ts [unit|integration|e2e|all]`.
|
||||
* Ogni file gira in un processo separato, così un test non può inquinare gli altri.
|
||||
* Senza argomenti esegue solo gli unit test (non serve rete né database).
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
const cartelle = { unit: "test/unit", integration: "test/integration", e2e: "test/e2e" };
|
||||
type Gruppo = keyof typeof cartelle;
|
||||
|
||||
const argomento = (process.argv[2] ?? "unit") as Gruppo | "all";
|
||||
const gruppi: Gruppo[] =
|
||||
argomento === "all" ? (Object.keys(cartelle) as Gruppo[]) : [argomento as Gruppo];
|
||||
|
||||
for (const g of gruppi) {
|
||||
if (!cartelle[g]) {
|
||||
console.error(`Gruppo sconosciuto: ${g}. Usa unit, integration, e2e oppure all.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const falliti: string[] = [];
|
||||
let totali = 0;
|
||||
|
||||
for (const gruppo of gruppi) {
|
||||
const cartella = cartelle[gruppo];
|
||||
const file = readdirSync(cartella)
|
||||
.filter((f) => f.endsWith(".test.ts"))
|
||||
.sort();
|
||||
|
||||
console.log(`\n── ${gruppo} (${file.length} file)`);
|
||||
for (const nome of file) {
|
||||
const percorso = `${cartella}/${nome}`;
|
||||
totali += 1;
|
||||
const esito = spawnSync("bun", [percorso], { stdio: "inherit" });
|
||||
if (esito.status !== 0) falliti.push(percorso);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${totali - falliti.length}/${totali} file ok` +
|
||||
(falliti.length ? `\nfalliti:\n ${falliti.join("\n ")}` : ""),
|
||||
);
|
||||
process.exit(falliti.length ? 1 : 0);
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Check del parsing CSI: `bun src/lib/csi-core.test.ts`.
|
||||
* Con CSI_LIVE=1 verifica anche gli endpoint reali del portale.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isNostraSquadra,
|
||||
parseClassifica,
|
||||
partiteDaEventi,
|
||||
partiteGiocate,
|
||||
urlClassifica,
|
||||
urlPartite,
|
||||
} from "@/lib/csi-core";
|
||||
|
||||
const htmlClassifica = `
|
||||
<table class="ranking">
|
||||
<tr><th>Pos</th><th>Squadra</th></tr>
|
||||
<tr><td>1</td><td><a href="team_details.php?team_id=1">Altra Squadra Girone A</a></td>
|
||||
<td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>42</td><td>18</td><td>24</td></tr>
|
||||
</table>
|
||||
<table class="ranking">
|
||||
<tr><th>Pos</th><th>Squadra</th></tr>
|
||||
<tr><td>1</td><td><a href="team_details.php?team_id=9">Paolo Poggi Volley & C.</a></td>
|
||||
<td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>43</td><td>17</td><td>26</td></tr>
|
||||
<tr class="align-middle">
|
||||
<!-- Posizione --><td>4</td>
|
||||
<td class="d-flex"><img src="images/teams/logos/CRAP_Volley_3359.jpg" alt="">
|
||||
<a href="team_details.php?team_id=3359">C.R.A.P. Volley </a></td>
|
||||
<td><b>30</b></td><td>16</td><td>10</td><td>6</td><td>3</td><td>3</td><td>36</td><td>28</td><td>8</td>
|
||||
<td>1427</td><td>1329</td><td>98</td><td>0.56</td><td>1.07</td>
|
||||
<td><svg viewBox="0 0 16 16"><path d="M16 8A8"/></svg></td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const righe = parseClassifica(htmlClassifica);
|
||||
assert.equal(righe.length, 2, "prende solo il girone che contiene la nostra squadra");
|
||||
assert.deepEqual(righe[1], {
|
||||
pos: 4,
|
||||
squadra: "C.R.A.P. Volley",
|
||||
punti: 30,
|
||||
giocate: 16,
|
||||
vinte: 10,
|
||||
perse: 6,
|
||||
setFatti: 36,
|
||||
setSubiti: 28,
|
||||
});
|
||||
assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML");
|
||||
assert.deepEqual(parseClassifica("<p>pagina cambiata</p>"), [], "portale cambiato: nessun crash");
|
||||
|
||||
assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley "));
|
||||
assert.ok(!isNostraSquadra("CRAP Volley B"));
|
||||
|
||||
const eventi = [
|
||||
{
|
||||
id: 1,
|
||||
start: "2025-11-12T22:00:00",
|
||||
team1: "C.R.A.P. Volley",
|
||||
team2: "AMCM",
|
||||
result: "3 - 1",
|
||||
partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>",
|
||||
field: "Sabin Piccolo",
|
||||
project: "PVM - Campionato Open Misto Eccellenza",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
start: "2025-11-19T22:00:00",
|
||||
team1: "Gnu Tim SdC",
|
||||
team2: "C.R.A.P. Volley",
|
||||
result: "3 - 2",
|
||||
partials: "25 - 21</br>18 - 25</br>",
|
||||
field: "BVI",
|
||||
project: "PVM - Campionato Open Misto Eccellenza",
|
||||
},
|
||||
{ id: 3, start: "2026-09-10T21:00:00", team1: "C.R.A.P. Volley", team2: "Esselle", result: "" },
|
||||
{ id: 4, start: "2025-10-01T21:00:00", team1: "Esselle", team2: "AMCM", result: "3 - 0" },
|
||||
];
|
||||
|
||||
const partite = partiteDaEventi(eventi);
|
||||
assert.equal(partite.length, 3, "scarta le gare di altre squadre");
|
||||
assert.equal(partite[0]!.data, "2026-09-10", "ordinate dalla più recente");
|
||||
assert.equal(partite[0]!.setNostri, null, "gara futura senza risultato");
|
||||
|
||||
const casa = partite.find((p) => p.id === "1")!;
|
||||
assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]);
|
||||
assert.deepEqual(casa.parziali[1], [23, 25]);
|
||||
assert.equal(casa.ora, "22:00");
|
||||
|
||||
const trasferta = partite.find((p) => p.id === "2")!;
|
||||
assert.deepEqual(
|
||||
[trasferta.casa, trasferta.avversario, trasferta.setNostri, trasferta.setLoro],
|
||||
[false, "Gnu Tim SdC", 2, 3],
|
||||
"in trasferta il punteggio va ribaltato",
|
||||
);
|
||||
assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali");
|
||||
|
||||
assert.equal(partiteGiocate(partite).length, 2);
|
||||
assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash");
|
||||
|
||||
if (process.env["CSI_LIVE"]) {
|
||||
const [html, json] = await Promise.all([
|
||||
fetch(urlClassifica()).then((r) => r.text()),
|
||||
fetch(urlPartite()).then((r) => r.json()),
|
||||
]);
|
||||
const live = parseClassifica(html);
|
||||
const nostra = live.find((r) => isNostraSquadra(r.squadra));
|
||||
assert.ok(nostra, "la nostra squadra è in classifica");
|
||||
assert.ok(live.length >= 8 && nostra.giocate > 0);
|
||||
const livePartite = partiteDaEventi(json);
|
||||
assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0);
|
||||
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`);
|
||||
}
|
||||
|
||||
console.log("csi-core: ok");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user