5 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 142a7fbaa9 Aggiungo a CLAUDE.md gli apprendimenti di sessione su tooling e ambiente locale
bun non è nel PATH in ambienti sandbox (usare npx/bunx), il repo non è
prettier-clean ovunque (filtrare i falsi positivi preesistenti), e la porta
Postgres locale può differire dal default 5432 nei .env di sviluppo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:41:14 +02:00
davideandClaude Sonnet 5 84313b1955 Aggiorno documentazione e memoria di progetto per il CMS Strapi
CLAUDE.md, docs/PORTABILITA.md e mem/features/cloud-efficienza.md riflettono
che rosa/classifica/storico partite vengono ora da Strapi (non più hardcoded)
e che lo scout finalizzato viene anche inviato a Strapi, oltre al localStorage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:40:08 +02:00
davideandClaude Sonnet 5 839db3d4ca Invio best-effort il risultato dello scout finalizzato a Strapi
Nuova route server /api/public/scout-finale: proxy verso il content type
scout-match-finale con un token Strapi di sola scrittura, mai esposto al
client. Il salvataggio in localStorage (già chiamato da finePartita() in
scout.tsx nel commit precedente) resta la fonte per le statistiche; questo
invio è additivo e non bloccante, se fallisce la partita resta comunque
salvata sul device come già accadeva prima.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:39:46 +02:00
davideandClaude Sonnet 5 953908388d Sposto rosa, classifica e storico partite dagli array hardcoded al CMS Strapi
Nuovi moduli src/lib/strapi-client.ts + rosa-base.ts/classifica-csi.ts/
storico-match.ts (hook TanStack Query, cache lunga, nessun polling). L'id
stabile gN diventa il campo `codice` su Strapi, per non rompere le FK verso
pagelle/cacche/mvp/palloni/presenze/azioni scout già chiavate su quella
stringa. isAdmin ora prende il Giocatore invece di un id, così non dipende
più dal roster. Rifattorizzati di conseguenza tutti i punti che importavano
`giocatori`/`classifica`/`storicoMatch` come array sincroni (componenti,
hook, due route server) per usare gli hook o un parametro esplicito.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:39:40 +02:00
davideandClaude Sonnet 5 3f61b32219 Aggiungo CMS Strapi self-hosted per gestire rosa, classifica e storico partite
Introduce lo stack Strapi dockerizzato (infra/strapi/), sullo stesso Postgres
dello stack Supabase con un database logico separato, dietro Caddy su /cms/*.
L'editing dei dati avviene solo nel pannello admin nativo di Strapi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:39:20 +02:00
83 changed files with 23213 additions and 385 deletions
+7
View File
@@ -6,6 +6,13 @@ VITE_SUPABASE_URL=https://tuodominio.it/api
VITE_SUPABASE_PUBLISHABLE_KEY= VITE_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
# CMS Strapi (rosa, classifica, storico partite, scout finalizzato) — vedi infra/strapi/.
# VITE_STRAPI_URL è pubblico (letture aperte, come le tabelle Supabase "open RLS"), mentre
# STRAPI_WRITE_TOKEN è un token con permessi di sola creazione su scout-match-finale: NON deve
# mai avere il prefisso VITE_ (finirebbe nel bundle client) e va letto solo lato server.
VITE_STRAPI_URL=https://tuodominio.it/cms
STRAPI_WRITE_TOKEN=
# Notifiche push (genera con: npx web-push generate-vapid-keys) # Notifiche push (genera con: npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
+33 -11
View File
@@ -24,6 +24,16 @@ bun run lint # eslint .
bun run format # prettier --write . bun run format # prettier --write .
``` ```
If `bun` isn't on PATH (sandboxed/CI environments), use the `npx`/`bunx` equivalents:
`npx tsc --noEmit -p tsconfig.json` (typecheck, no npm script exists for this),
`npx eslint src` (not `npx eslint .` — a full-repo scan chokes on the root-owned
`infra/supabase/docker/volumes/db/data` directory), `npx prettier --write <files>`.
This repo is not fully `prettier`-clean — a fresh `eslint`/`prettier --check` run surfaces
hundreds of pre-existing formatting errors unrelated to any given change. Filter with
`grep -v "prettier/prettier"` to see real lint issues, and run `prettier --write` only on the
files you touched, not the whole repo.
There is no test runner configured in this repo. There is no test runner configured in this repo.
Adding a dependency: `bunfig.toml` enforces a 24h supply-chain guard (`minimumReleaseAge`) on new Adding a dependency: `bunfig.toml` enforces a 24h supply-chain guard (`minimumReleaseAge`) on new
@@ -65,10 +75,11 @@ inspecting the response body and converted into `renderErrorPage()`'s HTML error
The project must remain deployable on plain Node.js + PostgreSQL, not locked into Lovable Cloud The project must remain deployable on plain Node.js + PostgreSQL, not locked into Lovable Cloud
(see `docs/PORTABILITA.md`). Concretely: (see `docs/PORTABILITA.md`). Concretely:
- **Never query the database from components.** All data access goes through modules in - **Never query the database (or the Strapi CMS) from components.** All data access goes through
`src/lib/*.ts` (e.g. `palloni.ts`, `mvp-voti.ts`, `eventi.ts`, `presenze.ts`, `rosa.ts`, modules in `src/lib/*.ts` (e.g. `palloni.ts`, `mvp-voti.ts`, `eventi.ts`, `presenze.ts`, `rosa.ts`,
`scout-*.ts`) — each exports TanStack Query hooks (`useX`) wrapping `supabase.from(...)`. This `scout-*.ts`) — each exports TanStack Query hooks (`useX`) wrapping `supabase.from(...)` or, for
keeps the backend swappable in one place. Strapi-backed data, `strapiFetch(...)` from `src/lib/strapi-client.ts` (see `rosa-base.ts`,
`classifica-csi.ts`, `storico-match.ts`). This keeps the backend swappable in one place.
- `src/integrations/lovable/*` (social login) is optional and unused by any screen — safe to - `src/integrations/lovable/*` (social login) is optional and unused by any screen — safe to
remove without impact. remove without impact.
- No provider-exclusive features: no edge functions, no Lovable-only auth as the sole login method, - No provider-exclusive features: no edge functions, no Lovable-only auth as the sole login method,
@@ -92,13 +103,24 @@ load-bearing, not style preferences:
- Stats/badges/standings are "write once, read many": computed and persisted once (e.g. at match - Stats/badges/standings are "write once, read many": computed and persisted once (e.g. at match
end), never recomputed on every page open. end), never recomputed on every page open.
- Live match scouting: only the scoring user writes; everyone else reads already-saved data. - Live match scouting: only the scoring user writes; everyone else reads already-saved data.
- CSI league data is currently a **hardcoded placeholder** array in `src/lib/crapp-data.ts` - Player roster, CSI league standings and match history come from a self-hosted **Strapi CMS**
(`classifica`, `storicoMatch`), explicitly marked "(demo)" in the UI — no real sync exists yet, (`infra/strapi/`, its own Postgres database on the same cluster as Supabase) — editing happens
this is planned/open work, not an existing pattern to copy. only in Strapi's own admin panel (`/cms/admin`), never in app UI. The app reads them read-only via
- A scouted match result (`ScoutMatch`, from `scout.tsx` / `scout-store.ts`) is saved **only to `src/lib/rosa-base.ts` / `classifica-csi.ts` / `storico-match.ts` (long `staleTime`, no polling —
the scoring device's `localStorage`** (key `crapp-scout-v1`), never to Supabase — only the same cloud-efficiency rules apply). Player identity is the stable `codice` field (`g1`, `g2`, …)
in-progress resume state goes to the `scout_live` table. So today a match result is not shared on the Strapi `giocatore` content type, **not** Strapi's own numeric id — every Supabase table
across devices/users at all. that references a player by id (`pagelle_voti`, `cacche_partita`, MVP votes, ball-duty turns,
presenze, scout actions) keys on that string, so it must never change for an existing player.
- A scouted match result (`ScoutMatch`, from `scout.tsx` / `scout-store.ts`) is saved to the
scoring device's `localStorage` (key `crapp-scout-v1`, still the source `useRosa`/
`giocatoriConScout` read for stats) **and** pushed best-effort to Strapi's `scout-match-finale`
content type via `/api/public/scout-finale` at match end, so it becomes visible cross-device in
the admin panel — no retry if that POST fails, the local save already succeeded either way. The
in-progress resume state still goes only to the `scout_live` Supabase table, unrelated to Strapi.
- Local dev quirk: this machine's `infra/supabase/docker/.env` may customize `POSTGRES_PORT`
away from the documented default `5432` (e.g. to avoid colliding with another project's
Postgres) — check that file before assuming the template's port when wiring up a new service
to the same database.
- Push notifications only for high-value events (convocations, training/match reminders, ball-duty - Push notifications only for high-value events (convocations, training/match reminders, ball-duty
turn, final result) — no chat/photo/video features. turn, final result) — no chat/photo/video features.
+30 -6
View File
@@ -152,24 +152,48 @@ Passo passo, sul server di produzione:
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f" docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
done done
``` ```
3. **Reverse proxy TLS**: copia `infra/supabase/docker/Caddyfile.example` in `Caddyfile`, 3. **Stack Strapi** (CMS admin per rosa, classifica, storico partite, scout finalizzato — vedi
`infra/strapi/`): usa lo stesso Postgres dello stack Supabase, in un database logico separato:
```sh
docker exec -i supabase-db psql -U postgres -c "CREATE DATABASE strapi;"
docker exec -i supabase-db psql -U postgres -c "CREATE USER strapi WITH PASSWORD '...';"
docker exec -i supabase-db psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE strapi TO strapi;"
cp infra/strapi/.env.production.example infra/strapi/.env
```
Compila in `infra/strapi/.env` i secret (`APP_KEYS`, `JWT_SECRET`, ecc. — genera ognuno con
`openssl rand -base64 32`) e `DATABASE_PASSWORD` (la stessa scelta sopra), poi:
```sh
cd infra/strapi
docker compose up -d --build
```
Al primo accesso su `<dominio>/cms/admin` crea l'utente amministratore Strapi. Da lì, un solo
giro di setup manuale (nessuna automazione: i permessi Strapi si abilitano solo dall'admin UI):
in Settings → Users & Permissions Plugin → Roles → Public abilita `find`/`findOne` per
`Giocatore`, `Riga-classifica` e `Match-storico`; poi in Settings → API Tokens genera un token
con permesso `create` solo su `Scout-match-finale` (va in `STRAPI_WRITE_TOKEN` nell'env
dell'app, punto 5). Infine inserisci a mano rosa/classifica/storico nelle rispettive collezioni
(i valori di partenza sono nella cronologia git di `src/lib/crapp-data.ts`, prima che venissero
spostati qui).
4. **Reverse proxy TLS**: copia `infra/supabase/docker/Caddyfile.example` in `Caddyfile`,
sostituisci il dominio del punto 0, poi `caddy run --config Caddyfile` (o come container). sostituisci il dominio del punto 0, poi `caddy run --config Caddyfile` (o come container).
Gestisce automaticamente il certificato Let's Encrypt. Gestisce automaticamente il certificato Let's Encrypt.
4. **Env dell'app**: 5. **Env dell'app**:
```sh ```sh
cp .env.production.example .env cp .env.production.example .env
``` ```
Compila `VITE_SUPABASE_URL` (dominio del punto 0 + `/api`), Compila `VITE_SUPABASE_URL` (dominio del punto 0 + `/api`),
`VITE_SUPABASE_PUBLISHABLE_KEY` (= `ANON_KEY` dello stack), `SUPABASE_SERVICE_ROLE_KEY` `VITE_SUPABASE_PUBLISHABLE_KEY` (= `ANON_KEY` dello stack), `SUPABASE_SERVICE_ROLE_KEY`
(= `SERVICE_ROLE_KEY`), e le chiavi `VAPID_*` reali (`npx web-push generate-vapid-keys`). (= `SERVICE_ROLE_KEY`), `VITE_STRAPI_URL` (dominio del punto 0 + `/cms`), `STRAPI_WRITE_TOKEN`
5. **Build e avvio**: (il token generato al punto 3), e le chiavi `VAPID_*` reali
(`npx web-push generate-vapid-keys`).
6. **Build e avvio**:
```sh ```sh
bun run build bun run build
node .output/server/index.mjs # ascolta su PORT (default 3000) node .output/server/index.mjs # ascolta su PORT (default 3000)
``` ```
Tienilo vivo con un process manager (pm2/systemd) — il Caddy del punto 3 lo espone via TLS Tienilo vivo con un process manager (pm2/systemd) — il Caddy del punto 4 lo espone via TLS
sull'hostname app. sull'hostname app.
6. **Job pianificati** — gli endpoint `src/routes/api/public/promemoria-palloni.ts` e 7. **Job pianificati** — gli endpoint `src/routes/api/public/promemoria-palloni.ts` e
`sollecita-presenze.ts` vanno richiamati periodicamente via HTTP POST da un cron di sistema o `sollecita-presenze.ts` vanno richiamati periodicamente via HTTP POST da un cron di sistema o
`pg_cron`: non partono da soli. `pg_cron`: non partono da soli.
+1
View File
@@ -15,6 +15,7 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. | | Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. | | `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. | | `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
| CMS Strapi (`infra/strapi/`) | Sì | Open source e self-hostable, Postgres come backend; l'app lo consuma solo via `src/lib/strapi-client.ts`. |
## Regole da rispettare nelle prossime modifiche ## Regole da rispettare nelle prossime modifiche
+8
View File
@@ -0,0 +1,8 @@
HOST=0.0.0.0
PORT=1337
APP_KEYS="toBeModified1,toBeModified2"
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified
+23
View File
@@ -0,0 +1,23 @@
# TEMPLATE DI PRODUZIONE per il CMS Strapi (rosa, classifica, storico partite, scout finalizzato).
# Copia in .env accanto a docker-compose.yml e compila. Rigenera SEMPRE secret nuovi in produzione,
# non riusare quelli di sviluppo — un valore per riga, generabili con: openssl rand -base64 32
HOST=0.0.0.0
PORT=1337
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
JWT_SECRET=
TRANSFER_TOKEN_SALT=
ENCRYPTION_KEY=
# Stesso cluster Postgres dello stack Supabase self-hosted (infra/supabase/docker/), database
# logico separato — vedi README "Produzione", passo Strapi.
DATABASE_CLIENT=postgres
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=
DATABASE_SSL=false
+131
View File
@@ -0,0 +1,131 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
.tsbuildinfo
.eslintcache
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Package managers
############################
.yarn/*
!.yarn/cache
!.yarn/unplugged
!.yarn/patches
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
yarn-error.log
############################
# Tests
############################
coverage
############################
# Strapi
############################
.env
license.txt
exports
.strapi
dist
build
.strapi-updater.json
.strapi-cloud.json
+15
View File
@@ -0,0 +1,15 @@
# Immagine di produzione per il CMS admin (rosa, classifica, storico partite, scout finalizzato).
# Build multi-stage: installa e builda l'admin panel, poi copia solo l'output nell'immagine finale.
FROM node:20-slim AS build
WORKDIR /opt/app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /opt/app
ENV NODE_ENV=production
COPY --from=build /opt/app .
EXPOSE 1337
CMD ["npm", "run", "start"]
+61
View File
@@ -0,0 +1,61 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
### `develop`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
```
npm run develop
# or
yarn develop
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
```
yarn strapi deploy
```
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
+21
View File
@@ -0,0 +1,21 @@
module.exports = ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
docLinks: env.bool('FLAG_DOC_LINKS', true),
},
});
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
strictParams: true,
},
documents: {
strictParams: true,
strictRelations: true,
},
};
+72
View File
@@ -0,0 +1,72 @@
/** @import { Core } from '@strapi/strapi' */
const path = require('path');
const { isDatabaseClientKind } = require('@strapi/database');
module.exports = ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
if (!isDatabaseClientKind(client)) {
throw new Error(
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`
);
}
/** @type {Record<Core.Config.Database.ClientKind, Core.Config.Database['connection']>} */
const connections = {
mysql: {
client: 'mysql',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
postgres: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
client: 'sqlite',
connection: {
filename: path.join(__dirname, '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
return {
connection: {
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};
+12
View File
@@ -0,0 +1,12 @@
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
+41
View File
@@ -0,0 +1,41 @@
const allowedMediaTypes = [
'image/*',
'video/*',
'audio/*',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.*',
'text/plain',
'text/csv',
];
const deniedTypes = [
'image/svg+xml',
'application/vnd.microsoft.portable-executable',
'application/x-msdownload',
'application/x-msdos-program',
'application/x-executable',
'application/x-dosexec',
'application/x-sh',
'text/x-shellscript',
'application/x-mach-binary',
];
module.exports = () => ({
'users-permissions': {
config: {
jwtManagement: 'refresh',
sessions: {
httpOnly: true,
},
},
},
upload: {
config: {
security: {
allowedTypes: allowedMediaTypes,
deniedTypes,
},
},
},
});
+10
View File
@@ -0,0 +1,10 @@
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
+38
View File
@@ -0,0 +1,38 @@
# CMS admin (rosa, classifica, storico partite, scout finalizzato) per CrAPP.
# Si appoggia allo stesso Postgres dello stack Supabase self-hosted (infra/supabase/docker/,
# progetto compose "supabase", rete di default "supabase_default" — verifica con
# `docker network ls` se il nome differisce) con un database logico separato ("strapi"), invece
# di un secondo container Postgres: stesso isolamento dei dati, metà del carico su hardware
# limitato (es. Raspberry Pi 4).
#
# Uso:
# cp .env.production.example .env # compila i secret, vedi README "Produzione"
# docker compose up -d
#
# Crea prima il database logico (una tantum, vedi README):
# docker exec -i supabase-db psql -U postgres -c "CREATE DATABASE strapi;"
# docker exec -i supabase-db psql -U postgres -c "CREATE USER strapi WITH PASSWORD '...';"
# docker exec -i supabase-db psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE strapi TO strapi;"
services:
strapi:
build: .
container_name: crapp-strapi
restart: unless-stopped
env_file:
- .env
ports:
# Solo locale, come api-gw dello stack Supabase — dietro il reverse proxy TLS (Caddyfile).
- "127.0.0.1:1337:1337"
volumes:
- strapi-uploads:/opt/app/public/uploads
networks:
- supabase
networks:
supabase:
name: supabase_default
external: true
volumes:
strapi-uploads:
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ES2021",
"checkJs": true,
"allowJs": true
}
}
+21579
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "strapi",
"version": "0.1.0",
"private": true,
"description": "A Strapi application",
"scripts": {
"build": "strapi build",
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/database": "5.52.2",
"@strapi/plugin-cloud": "5.52.2",
"@strapi/plugin-users-permissions": "5.52.2",
"@strapi/strapi": "5.52.2",
"pg": "8.20.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.30.3",
"styled-components": "^6.0.0"
},
"devDependencies": {},
"engines": {
"node": ">=20.0.0 <=26.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "7cc5aaf5-d55f-4ee8-9141-02e2dd523a1c",
"installId": "8e493229589483c80fc99464f570c7c84540d2c64f9fe878c9677834e48e6fa1"
},
"allowScripts": {
"esbuild@0.28.2": true,
"esbuild@0.21.5": true,
"@swc/core@1.16.1": true,
"core-js-pure@3.50.0": true
}
}
+3
View File
@@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /
+39
View File
@@ -0,0 +1,39 @@
const config = {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'da',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
};
const bootstrap = (app) => {
console.log(app);
};
export default {
config,
bootstrap,
};
@@ -0,0 +1,12 @@
const { mergeConfig } = require('vite');
module.exports = (config) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
'@': '/src',
},
},
});
};
View File
@@ -0,0 +1,38 @@
{
"kind": "collectionType",
"collectionName": "giocatori",
"info": {
"singularName": "giocatore",
"pluralName": "giocatoris",
"displayName": "Giocatore",
"description": "Anagrafica rosa CRAP Volley. Le statistiche (presenze, MVP, ecc.) restano calcolate lato app, non vivono qui."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"codice": {
"type": "string",
"required": true,
"unique": true,
"regex": "^g[0-9]+$"
},
"nome": {
"type": "string",
"required": true
},
"numero": {
"type": "integer",
"required": true
},
"ruolo": {
"type": "string",
"required": true
},
"nascita": {
"type": "date",
"required": true
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::giocatore.giocatore');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::giocatore.giocatore');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::giocatore.giocatore');
@@ -0,0 +1,43 @@
{
"kind": "collectionType",
"collectionName": "match_storici",
"info": {
"singularName": "match-storico",
"pluralName": "match-storicos",
"displayName": "Match storico",
"description": "Storico partite di campionato CSI."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"data": {
"type": "date",
"required": true
},
"avversario": {
"type": "string",
"required": true
},
"casa": {
"type": "boolean",
"required": true,
"default": true
},
"set_nostri": {
"type": "integer",
"required": true
},
"set_loro": {
"type": "integer",
"required": true
},
"parziali": {
"type": "json"
},
"mvp": {
"type": "string"
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::match-storico.match-storico');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::match-storico.match-storico');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::match-storico.match-storico');
@@ -0,0 +1,54 @@
{
"kind": "collectionType",
"collectionName": "righe_classifica",
"info": {
"singularName": "riga-classifica",
"pluralName": "riga-classificas",
"displayName": "Riga classifica",
"description": "Classifica campionato CSI, una riga per squadra."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"pos": {
"type": "integer",
"required": true
},
"squadra": {
"type": "string",
"required": true
},
"giocate": {
"type": "integer",
"required": true,
"default": 0
},
"vinte": {
"type": "integer",
"required": true,
"default": 0
},
"perse": {
"type": "integer",
"required": true,
"default": 0
},
"set_fatti": {
"type": "integer",
"required": true,
"default": 0
},
"set_subiti": {
"type": "integer",
"required": true,
"default": 0
},
"punti": {
"type": "integer",
"required": true,
"default": 0
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::riga-classifica.riga-classifica');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::riga-classifica.riga-classifica');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::riga-classifica.riga-classifica');
@@ -0,0 +1,51 @@
{
"kind": "collectionType",
"collectionName": "scout_match_finales",
"info": {
"singularName": "scout-match-finale",
"pluralName": "scout-match-finales",
"displayName": "Scout match finale",
"description": "Risultato finale di una partita scoutata dal vivo (dato storico, di sola consultazione). L'azione-per-azione resta in scout_live/localStorage durante la partita."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"match_id": {
"type": "string",
"required": true,
"unique": true
},
"data": {
"type": "date",
"required": true
},
"avversario": {
"type": "string",
"required": true
},
"casa": {
"type": "boolean",
"required": true,
"default": true
},
"set_nostri": {
"type": "integer",
"required": true
},
"set_loro": {
"type": "integer",
"required": true
},
"parziali": {
"type": "json"
},
"mvp": {
"type": "string"
},
"azioni": {
"type": "json"
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::scout-match-finale.scout-match-finale');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::scout-match-finale.scout-match-finale');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::scout-match-finale.scout-match-finale');
+6
View File
@@ -0,0 +1,6 @@
'use strict';
module.exports = {
register(/*{ strapi }*/) {},
bootstrap(/*{ strapi }*/) {},
};
+7 -3
View File
@@ -4,14 +4,18 @@
# (o come container: docker run -p 80:80 -p 443:443 -v ./Caddyfile:/etc/caddy/Caddyfile caddy) # (o come container: docker run -p 80:80 -p 443:443 -v ./Caddyfile:/etc/caddy/Caddyfile caddy)
# #
# Un solo hostname: /api/* va al gateway Supabase (envoy accetta qualsiasi Host, instrada per # Un solo hostname: /api/* va al gateway Supabase (envoy accetta qualsiasi Host, instrada per
# path), tutto il resto va all'app. handle_path toglie il prefisso /api prima di inoltrare, quindi # path), /cms/* va al CMS Strapi (infra/strapi/), tutto il resto va all'app. handle_path toglie
# VITE_SUPABASE_URL nell'app deve essere https://<dominio>/api (supabase-js aggiunge da solo # il prefisso prima di inoltrare, quindi VITE_SUPABASE_URL nell'app deve essere
# /rest/v1, /auth/v1, ecc.). # https://<dominio>/api (supabase-js aggiunge da solo /rest/v1, /auth/v1, ecc.) e VITE_STRAPI_URL
# deve essere https://<dominio>/cms.
crapp.ddns.net { crapp.ddns.net {
handle_path /api/* { handle_path /api/* {
reverse_proxy 127.0.0.1:8000 reverse_proxy 127.0.0.1:8000
} }
handle_path /cms/* {
reverse_proxy 127.0.0.1:1337
}
handle { handle {
reverse_proxy 127.0.0.1:3000 reverse_proxy 127.0.0.1:3000
} }
+2 -1
View File
@@ -8,7 +8,8 @@ type: feature
- Dopo una mutazione aggiornare la cache con `setQueryData`, non `invalidateQueries` (evita riletture). - 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. - 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. - Statistiche, badge e classifiche: "write once, read many" — calcolate e salvate una volta a fine partita, mai ricalcolate a ogni apertura pagina.
- Dati CSI: al momento è un array hardcoded in `src/lib/crapp-data.ts`, marcato "(demo)" in UI — nessun sync reale, da implementare. Anche il risultato di una partita scoutata (`ScoutMatch`) oggi vive solo nel `localStorage` del dispositivo di chi scouta, non su Supabase. - Rosa giocatori, classifica CSI e storico partite vengono ora dal CMS Strapi (`infra/strapi/`, letto via `src/lib/rosa-base.ts`/`classifica-csi.ts`/`storico-match.ts`), non più da array hardcoded — editing solo dall'admin panel Strapi, l'app resta sola lettura (più una scrittura best-effort per lo scout finalizzato, vedi sotto). Cache lunga (staleTime 60 min): questi dati cambiano a stagione/giornata, non a sessione.
- Il risultato di una partita scoutata (`ScoutMatch`) resta salvato anzitutto nel `localStorage` del dispositivo di chi scouta (fonte usata da `useRosa`/`giocatoriConScout` per le statistiche), e viene *anche* inviato best-effort a Strapi (`scout-match-finale`, via `/api/public/scout-finale`) a fine partita per restare visibile cross-device nell'admin panel — nessun retry se il device non ha rete in quel momento.
- Push solo per eventi importanti: convocazioni, promemoria allenamento/partita, turno palloni, esito finale. - Push solo per eventi importanti: convocazioni, promemoria allenamento/partita, turno palloni, esito finale.
- Niente foto/video/chat o funzionalità pesanti. - 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. - Schema target: team_id, eventi, presenze, azioni_scout, statistiche_aggregate, classifica_csi, notifiche, con indici sui campi di filtro/relazione.
+6 -2
View File
@@ -1,11 +1,12 @@
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react"; import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatData, giocatori, statoMeta, type Stato } from "@/lib/crapp-data"; import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { Barra } from "@/components/motion/Barra"; import { Barra } from "@/components/motion/Barra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { useRosaBase } from "@/lib/rosa-base";
const tipoMeta = { const tipoMeta = {
partita: { label: "Partita", className: "bg-accent text-accent-foreground" }, partita: { label: "Partita", className: "bg-accent text-accent-foreground" },
@@ -36,6 +37,7 @@ export function EventoCard({
const { risposte } = usePresenzeEvento(evento.id); const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza(); const salva = useSalvaPresenza();
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const stato = io ? risposte[io.id] : undefined; const stato = io ? risposte[io.id] : undefined;
const presentiVeri = giocatori.filter( const presentiVeri = giocatori.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -136,7 +138,9 @@ export function EventoCard({
} }
className={cn( className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95", "rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
attivo ? cn(meta.className, "border-transparent shadow-card") : "bg-background text-muted-foreground", attivo
? cn(meta.className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
)} )}
> >
{meta.label} {meta.label}
+11 -6
View File
@@ -3,7 +3,8 @@ import { ClipboardCheck, Lock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { giocatori } from "@/lib/crapp-data"; import type { Giocatore } from "@/lib/crapp-data";
import { useRosaBase } from "@/lib/rosa-base";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle"; import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
@@ -12,21 +13,23 @@ const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
/** Pagelle di fine partita: voto anonimo 1-10 a ciascun compagno. */ /** Pagelle di fine partita: voto anonimo 1-10 a ciascun compagno. */
export function Pagelle({ export function Pagelle({
matchId, matchId,
convocati = giocatori, convocati,
chiuse = false, chiuse = false,
}: { }: {
matchId: string; matchId: string;
convocati?: typeof giocatori; convocati?: Giocatore[];
chiuse?: boolean; chiuse?: boolean;
}) { }) {
const rosaBase = useRosaBase();
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const { voti: tutti, isPending } = usePagelle(); const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella(); const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null); const [apertoPer, setApertoPer] = useState<string | null>(null);
const lista = convocati ?? rosaBase;
const medie = pagellePartita(tutti, matchId); const medie = pagellePartita(tutti, matchId);
const miei = io ? mieiVoti(tutti, matchId, io.id) : {}; const miei = io ? mieiVoti(tutti, matchId, io.id) : {};
const daVotare = convocati.filter((g) => g.id !== io?.id); const daVotare = lista.filter((g) => g.id !== io?.id);
const fatti = daVotare.filter((g) => miei[g.id] !== undefined).length; const fatti = daVotare.filter((g) => miei[g.id] !== undefined).length;
async function invia(votatoId: string, voto: number) { async function invia(votatoId: string, voto: number) {
@@ -58,7 +61,7 @@ export function Pagelle({
<p className="mt-3 text-xs text-muted-foreground">Carico le pagelle</p> <p className="mt-3 text-xs text-muted-foreground">Carico le pagelle</p>
) : ( ) : (
<div className="mt-3 space-y-1.5"> <div className="mt-3 space-y-1.5">
{convocati.map((g) => { {lista.map((g) => {
const media = medie[g.id]; const media = medie[g.id];
const mio = miei[g.id]; const mio = miei[g.id];
const sonoIo = g.id === io?.id; const sonoIo = g.id === io?.id;
@@ -104,7 +107,9 @@ export function Pagelle({
onClick={() => invia(g.id, v)} onClick={() => invia(g.id, v)}
className={cn( className={cn(
"rounded-lg py-1.5 text-[11px] font-bold tabular-nums transition-transform active:scale-90", "rounded-lg py-1.5 text-[11px] font-bold tabular-nums transition-transform active:scale-90",
mio === v ? "bg-accent text-accent-foreground" : "bg-card text-foreground", mio === v
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
)} )}
> >
{v} {v}
+7 -4
View File
@@ -4,7 +4,8 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra"; import { Barra } from "@/components/motion/Barra";
import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data"; import { isAdmin, statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { useRosaBase } from "@/lib/rosa-base";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
@@ -14,12 +15,14 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
const { risposte, isPending } = usePresenzeEvento(eventoId); const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza(); const salva = useSalvaPresenza();
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const [sollecito, setSollecito] = useState(false); const [sollecito, setSollecito] = useState(false);
const mancanti = giocatori.filter((g) => !risposte[g.id]); const mancanti = giocatori.filter((g) => !risposte[g.id]);
const risposteN = giocatori.length - mancanti.length; const risposteN = giocatori.length - mancanti.length;
const perc = Math.round((risposteN / giocatori.length) * 100); const perc = Math.round((risposteN / giocatori.length) * 100);
const daSollecitare = mancanti.length + giocatori.filter((g) => risposte[g.id] === "forse").length; const daSollecitare =
mancanti.length + giocatori.filter((g) => risposte[g.id] === "forse").length;
async function sollecita() { async function sollecita() {
setSollecito(true); setSollecito(true);
@@ -105,7 +108,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
</div> </div>
) : null} ) : null}
{io && isAdmin(io.id) ? ( {io && isAdmin(io) ? (
<button <button
type="button" type="button"
onClick={sollecita} onClick={sollecita}
@@ -159,7 +162,7 @@ function Gruppo({
}: { }: {
titolo: string; titolo: string;
n: number; n: number;
lista: typeof giocatori; lista: Giocatore[];
attenzione?: boolean; attenzione?: boolean;
}) { }) {
return ( return (
+1 -1
View File
@@ -12,7 +12,7 @@ export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "com
const { data: sessione } = useSessioneScout(partita?.id ?? null); const { data: sessione } = useSessioneScout(partita?.id ?? null);
// Strumento tecnico: solo i referenti/allenatori scoutizzano la partita. // Strumento tecnico: solo i referenti/allenatori scoutizzano la partita.
const abilitato = !!io && isAdmin(io.id); const abilitato = !!io && isAdmin(io);
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null; const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
const occupato = !!attiva && attiva.giocatore_id !== io?.id; const occupato = !!attiva && attiva.giocatore_id !== io?.id;
+6 -2
View File
@@ -1,6 +1,6 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { giocatori } from "@/lib/crapp-data"; import { useRosaBase } from "@/lib/rosa-base";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { mediaPartita, useCacche, useSalvaCacche } from "@/lib/cacche"; import { mediaPartita, useCacche, useSalvaCacche } from "@/lib/cacche";
@@ -9,6 +9,7 @@ const opzioni = [0, 1, 2, 3, 4, 5];
/** Sondaggio goliardico pre-partita: quante cacche prima del fischio d'inizio. */ /** Sondaggio goliardico pre-partita: quante cacche prima del fischio d'inizio. */
export function SondaggioCacche({ eventoId }: { eventoId: string }) { export function SondaggioCacche({ eventoId }: { eventoId: string }) {
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const { righe } = useCacche(); const { righe } = useCacche();
const salva = useSalvaCacche(); const salva = useSalvaCacche();
@@ -68,7 +69,10 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
Media squadra {media} Media squadra {media}
</span> </span>
{classifica.map((r, i) => ( {classifica.map((r, i) => (
<span key={r.giocatore_id} className="rounded-full bg-secondary px-2.5 py-1 font-semibold"> <span
key={r.giocatore_id}
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
>
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita} {i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
</span> </span>
))} ))}
+2 -1
View File
@@ -3,12 +3,13 @@ import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { giocatori } from "@/lib/crapp-data"; import { useRosaBase } from "@/lib/rosa-base";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni"; import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
export function TurnoPalloni({ eventoId }: { eventoId: string }) { export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const [aperto, setAperto] = useState(false); const [aperto, setAperto] = useState(false);
const giocatori = useRosaBase();
const { salvati, turni, isPending } = useTurniPalloni(); const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno(); const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
+2 -1
View File
@@ -2,13 +2,14 @@ import { useState } from "react";
import { Crown, Vote } from "lucide-react"; import { Crown, Vote } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { giocatori } from "@/lib/crapp-data"; import { useRosaBase } from "@/lib/rosa-base";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti"; import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
/** Pannello di votazione MVP di una partita: un voto a testa, modificabile. */ /** Pannello di votazione MVP di una partita: un voto a testa, modificabile. */
export function VotazioneMvp({ matchId }: { matchId: string }) { export function VotazioneMvp({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const voti = useVotiMvp(); const voti = useVotiMvp();
const vota = useVotaMvp(); const vota = useVotaMvp();
const [aperto, setAperto] = useState(false); const [aperto, setAperto] = useState(false);
+3 -3
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Check, Crown, Sparkles } from "lucide-react"; import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { giocatori } from "@/lib/crapp-data"; import { useRosaBase } from "@/lib/rosa-base";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { import {
categorieSocial, categorieSocial,
@@ -16,6 +16,7 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */ /** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) { export function VotoSocial({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const voti = useVotiSocial(); const voti = useVotiSocial();
const vota = useVotaSocial(); const vota = useVotaSocial();
const [aperta, setAperta] = useState<string | null>(null); const [aperta, setAperta] = useState<string | null>(null);
@@ -122,8 +123,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
<p className="inline-flex items-center gap-1.5 text-xs font-bold"> <p className="inline-flex items-center gap-1.5 text-xs font-bold">
<Crown className="h-3.5 w-3.5 text-oro" /> <Crown className="h-3.5 w-3.5 text-oro" />
<Icon className="h-3.5 w-3.5 text-accent" /> <Icon className="h-3.5 w-3.5 text-accent" />
{vincitore.nome} · {vincitore.voti}{" "} {vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
{vincitore.voti === 1 ? "voto" : "voti"}
</p> </p>
) : ( ) : (
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
+44
View File
@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { strapiFetch } from "./strapi-client";
import type { RigaClassifica } from "./crapp-data";
type RigaClassificaStrapi = {
pos: number;
squadra: string;
giocate: number;
vinte: number;
perse: number;
set_fatti: number;
set_subiti: number;
punti: number;
};
function daEntry(e: RigaClassificaStrapi): RigaClassifica {
return {
pos: e.pos,
squadra: e.squadra,
giocate: e.giocate,
vinte: e.vinte,
perse: e.perse,
setFatti: e.set_fatti,
setSubiti: e.set_subiti,
punti: e.punti,
};
}
export const CLASSIFICA_KEY = ["classifica-csi"] as const;
async function fetchClassifica(): Promise<RigaClassifica[]> {
const entries = await strapiFetch<RigaClassificaStrapi>("riga-classificas?sort=pos");
return entries.map(daEntry);
}
/** Classifica campionato CSI dal CMS: cambia a giornata, non a sessione. */
export function useClassificaCsi(): RigaClassifica[] {
const query = useQuery({
queryKey: CLASSIFICA_KEY,
queryFn: fetchClassifica,
staleTime: 60 * 60_000,
});
return query.data ?? [];
}
+12 -105
View File
@@ -2,10 +2,18 @@ export type Stato = "presente" | "assente" | "forse" | "ritardo" | "infortunato"
export const statoMeta: Record<Stato, { label: string; emoji: string; className: string }> = { export const statoMeta: Record<Stato, { label: string; emoji: string; className: string }> = {
presente: { label: "Presente", emoji: "✅", className: "bg-success text-success-foreground" }, presente: { label: "Presente", emoji: "✅", className: "bg-success text-success-foreground" },
assente: { label: "Assente", emoji: "❌", className: "bg-destructive text-destructive-foreground" }, assente: {
label: "Assente",
emoji: "❌",
className: "bg-destructive text-destructive-foreground",
},
forse: { label: "Forse", emoji: "🤔", className: "bg-warning text-warning-foreground" }, forse: { label: "Forse", emoji: "🤔", className: "bg-warning text-warning-foreground" },
ritardo: { label: "In ritardo", emoji: "⏱️", className: "bg-info text-info-foreground" }, ritardo: { label: "In ritardo", emoji: "⏱️", className: "bg-info text-info-foreground" },
infortunato: { label: "Infortunato", emoji: "🩹", className: "bg-primary text-primary-foreground" }, infortunato: {
label: "Infortunato",
emoji: "🩹",
className: "bg-primary text-primary-foreground",
},
}; };
/** /**
@@ -42,89 +50,6 @@ export type Giocatore = {
iniziali: string; iniziali: string;
}; };
type Rosa = { nome: string; nascita: string; ruolo: string; numero?: number };
/** Rosa reale CRAP Volley (tesseramento CSI Bologna 25/26). */
const rosaCSI: Rosa[] = [
{ nome: "Salvador Battistella", nascita: "1997-08-30", ruolo: "Libero", numero: 88 },
{ nome: "Mattias Bologna", nascita: "1996-12-07", ruolo: "Centrale", numero: 73 },
{ nome: "Alessandra Brunacci", nascita: "2000-04-28", ruolo: "Palleggiatore", numero: 8 },
{ nome: "Ivan Cacciari", nascita: "1995-05-01", ruolo: "Banda", numero: 23 },
{ nome: "Mattia Catalano", nascita: "1995-08-13", ruolo: "Palleggiatore", numero: 21 },
{ nome: "Silvia Chilese", nascita: "1996-02-01", ruolo: "Libero", numero: 11 },
{ nome: "Alessio Cocco", nascita: "1997-07-04", ruolo: "Centrale", numero: 77 },
{ nome: "Carlo Di Castelnuovo", nascita: "1994-05-28", ruolo: "Opposto", numero: 14 },
{ nome: "Camilla Esposito", nascita: "2003-04-04", ruolo: "Palleggiatore", numero: 7 },
{ nome: "Davide Grilli", nascita: "1998-11-30", ruolo: "Opposto", numero: 1 },
{ nome: "Antonella Loverre", nascita: "2000-02-02", ruolo: "Banda", numero: 22 },
{ nome: "Laura Passabì", nascita: "1999-10-03", ruolo: "Banda", numero: 5 },
{ nome: "Nicola Pezzoli", nascita: "2000-09-16", ruolo: "Centrale", numero: 4 },
{ nome: "Iacopo Ricci", nascita: "1996-12-13", ruolo: "Banda", numero: 2 },
{ nome: "Cristina Titone", nascita: "1993-03-24", ruolo: "Libero", numero: 3 },
{ nome: "Francesca Tucci", nascita: "2001-04-18", ruolo: "Centrale", numero: 18 },
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
];
function inizialiDa(nome: string) {
return nome
.split(" ")
.map((p) => p[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
type StatsDemo = Pick<Giocatore, "presenze" | "streak" | "mvp" | "mediaVoto">;
/** Statistiche demo: mix di badge bronzo / argento / oro già sbloccati. */
const statsDemo: Record<string, StatsDemo> = {
"Ivan Cacciari": { presenze: 31, streak: 8, mvp: 5, mediaVoto: 8.7 },
"Davide Grilli": { presenze: 27, streak: 5, mvp: 3, mediaVoto: 8.4 },
"Nicola Pezzoli": { presenze: 24, streak: 4, mvp: 2, mediaVoto: 8.2 },
"Laura Passabì": { presenze: 22, streak: 6, mvp: 3, mediaVoto: 8.1 },
"Francesca Tucci": { presenze: 19, streak: 3, mvp: 1, mediaVoto: 7.9 },
"Iacopo Ricci": { presenze: 21, streak: 2, mvp: 3, mediaVoto: 7.8 },
"Alessandra Brunacci": { presenze: 14, streak: 3, mvp: 1, mediaVoto: 7.5 },
"Mattias Bologna": { presenze: 12, streak: 2, mvp: 1, mediaVoto: 7.4 },
"Giada Valbonesi": { presenze: 13, streak: 4, mvp: 1, mediaVoto: 7.6 },
"Alessio Cocco": { presenze: 11, streak: 1, mvp: 0, mediaVoto: 7.2 },
"Mattia Catalano": { presenze: 9, streak: 2, mvp: 0, mediaVoto: 7.0 },
"Antonella Loverre": { presenze: 8, streak: 1, mvp: 0, mediaVoto: 6.9 },
"Carlo Di Castelnuovo": { presenze: 7, streak: 1, mvp: 0, mediaVoto: 6.8 },
"Camilla Esposito": { presenze: 6, streak: 2, mvp: 0, mediaVoto: 6.7 },
"Salvador Battistella": { presenze: 20, streak: 5, mvp: 1, mediaVoto: 7.7 },
"Silvia Chilese": { presenze: 16, streak: 3, mvp: 0, mediaVoto: 7.3 },
"Cristina Titone": { presenze: 15, streak: 2, mvp: 0, mediaVoto: 7.1 },
};
/** Serie demo derivate dalle statistiche: ogni tipo ha il suo contatore. */
function serieDa(s?: StatsDemo) {
const streak = s?.streak ?? 0;
const presenze = s?.presenze ?? 0;
return {
serieAllenamenti: streak,
seriePartite: Math.ceil(streak / 2),
serieConferme: presenze >= 20 ? 12 : presenze >= 14 ? 8 : presenze >= 8 ? 4 : 1,
};
}
export const giocatori: Giocatore[] = rosaCSI.map((r, i) => ({
id: `g${i + 1}`,
nome: r.nome,
numero: r.numero ?? 0,
ruolo: r.ruolo,
nascita: r.nascita,
iniziali: inizialiDa(r.nome),
totaliEventi: 32,
infortuni: 0,
ritardi: 0,
palloni: 0,
cacche: 0,
cacchePartita: 0,
...(statsDemo[r.nome] ?? { presenze: 0, streak: 0, mvp: 0, mediaVoto: 0 }),
...serieDa(statsDemo[r.nome]),
}));
export type Match = { export type Match = {
id: string; id: string;
data: string; data: string;
@@ -136,13 +61,6 @@ export type Match = {
mvp: string; mvp: string;
}; };
export const storicoMatch: Match[] = [
{ id: "m1", data: "2026-07-25", avversario: "Pallavolo Sesto", casa: true, setNostri: 3, setLoro: 1, parziali: [[25, 19], [23, 25], [25, 21], [25, 18]], mvp: "Davide Grilli" },
{ id: "m2", data: "2026-07-18", avversario: "ASD Rondinella", casa: false, setNostri: 2, setLoro: 3, parziali: [[25, 22], [19, 25], [25, 23], [20, 25], [12, 15]], mvp: "Ivan Cacciari" },
{ id: "m3", data: "2026-07-11", avversario: "Virtus Cinisello", casa: true, setNostri: 3, setLoro: 0, parziali: [[25, 15], [25, 20], [25, 17]], mvp: "Laura Passabì" },
{ id: "m4", data: "2026-07-04", avversario: "Nuova Bovisa", casa: false, setNostri: 3, setLoro: 2, parziali: [[21, 25], [25, 23], [18, 25], [25, 20], [15, 11]], mvp: "Nicola Pezzoli" },
];
export type RigaClassifica = { export type RigaClassifica = {
pos: number; pos: number;
squadra: string; squadra: string;
@@ -154,16 +72,6 @@ export type RigaClassifica = {
punti: number; punti: number;
}; };
export const classifica: RigaClassifica[] = [
{ pos: 1, squadra: "ASD Rondinella", giocate: 12, vinte: 10, perse: 2, setFatti: 33, setSubiti: 12, punti: 29 },
{ pos: 2, squadra: "CRAP Volley", giocate: 12, vinte: 9, perse: 3, setFatti: 31, setSubiti: 16, punti: 26 },
{ pos: 3, squadra: "Pallavolo Sesto", giocate: 12, vinte: 8, perse: 4, setFatti: 29, setSubiti: 19, punti: 24 },
{ pos: 4, squadra: "Volley Bruzzano", giocate: 12, vinte: 7, perse: 5, setFatti: 26, setSubiti: 21, punti: 21 },
{ pos: 5, squadra: "Aurora Nera", giocate: 12, vinte: 5, perse: 7, setFatti: 22, setSubiti: 25, punti: 16 },
{ pos: 6, squadra: "Virtus Cinisello", giocate: 12, vinte: 3, perse: 9, setFatti: 15, setSubiti: 30, punti: 10 },
{ pos: 7, squadra: "Nuova Bovisa", giocate: 12, vinte: 2, perse: 10, setFatti: 13, setSubiti: 32, punti: 7 },
];
export function formatData(iso: string) { export function formatData(iso: string) {
const d = new Date(iso + "T00:00:00"); const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" }); return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
@@ -172,7 +80,6 @@ export function formatData(iso: string) {
/** Referenti che possono gestire eventi e sollecitare le risposte. */ /** Referenti che possono gestire eventi e sollecitare le risposte. */
export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"]; export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"];
export function isAdmin(giocatoreId: string) { export function isAdmin(giocatore: Pick<Giocatore, "nome"> | null | undefined) {
const g = giocatori.find((x) => x.id === giocatoreId); return Boolean(giocatore && adminNomi.includes(giocatore.nome));
return Boolean(g && adminNomi.includes(g.nome));
} }
+6 -3
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { giocatori } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno"; export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
@@ -158,7 +158,10 @@ export function useEliminaEvento() {
} }
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */ /** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
export function compleanniEventi(anno = new Date().getFullYear()): Evento[] { export function compleanniEventi(
giocatori: Giocatore[],
anno = new Date().getFullYear(),
): Evento[] {
return giocatori return giocatori
.map((g) => { .map((g) => {
const md = g.nascita.slice(5); const md = g.nascita.slice(5);
@@ -181,7 +184,7 @@ export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
} }
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */ /** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
export function convocatiEvento(evento: Evento | null) { export function convocatiEvento(giocatori: Giocatore[], evento: Evento | null) {
if (!evento || evento.convocati.length === 0) return giocatori; if (!evento || evento.convocati.length === 0) return giocatori;
return giocatori.filter((g) => evento.convocati.includes(g.id)); return giocatori.filter((g) => evento.convocati.includes(g.id));
} }
+18 -14
View File
@@ -1,4 +1,4 @@
import { giocatori, storicoMatch, type Giocatore } from "./crapp-data"; import type { Giocatore, Match } from "./crapp-data";
import type { Evento } from "./eventi"; import type { Evento } from "./eventi";
import type { MappaPresenze } from "./presenze"; import type { MappaPresenze } from "./presenze";
import { mediaSquadra, type VotoPagella } from "./pagelle"; import { mediaSquadra, type VotoPagella } from "./pagelle";
@@ -26,12 +26,12 @@ export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, page
const MESE = "2026-08"; const MESE = "2026-08";
function percentualePresenzeMese(ctx: ContestoObiettivi) { function percentualePresenzeMese(ctx: ContestoObiettivi, numGiocatori: number) {
const delMese = ctx.eventi.filter( const delMese = ctx.eventi.filter(
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"), (e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
); );
if (delMese.length === 0) return 0; if (delMese.length === 0 || numGiocatori === 0) return 0;
const posti = delMese.length * giocatori.length; const posti = delMese.length * numGiocatori;
const presenti = delMese.reduce((s, e) => { const presenti = delMese.reduce((s, e) => {
const risposte = ctx.presenze[e.id] ?? {}; const risposte = ctx.presenze[e.id] ?? {};
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length; return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
@@ -39,10 +39,10 @@ function percentualePresenzeMese(ctx: ContestoObiettivi) {
return Math.round((presenti / posti) * 100); return Math.round((presenti / posti) * 100);
} }
function percentualeRisposte(ctx: ContestoObiettivi) { function percentualeRisposte(ctx: ContestoObiettivi, numGiocatori: number) {
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno"); const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
if (daRispondere.length === 0) return 0; if (daRispondere.length === 0 || numGiocatori === 0) return 0;
const posti = daRispondere.length * giocatori.length; const posti = daRispondere.length * numGiocatori;
const risposte = daRispondere.reduce( const risposte = daRispondere.reduce(
(s, e) => s + Object.keys(ctx.presenze[e.id] ?? {}).length, (s, e) => s + Object.keys(ctx.presenze[e.id] ?? {}).length,
0, 0,
@@ -50,21 +50,21 @@ function percentualeRisposte(ctx: ContestoObiettivi) {
return Math.round((risposte / posti) * 100); return Math.round((risposte / posti) * 100);
} }
const vittorie = storicoMatch.filter((m) => m.setNostri > m.setLoro).length;
/** Obiettivi collaborativi: si muovono con il contributo di tutta la rosa. */ /** Obiettivi collaborativi: si muovono con il contributo di tutta la rosa. */
export function obiettiviSquadra( export function obiettiviSquadra(
rosa: Giocatore[] = giocatori, rosa: Giocatore[] = [],
ctx: ContestoObiettivi = contestoVuoto, ctx: ContestoObiettivi = contestoVuoto,
storicoMatch: Match[] = [],
): ObiettivoSquadra[] { ): ObiettivoSquadra[] {
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0); const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length; const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
const vittorie = storicoMatch.filter((m) => m.setNostri > m.setLoro).length;
return [ return [
{ {
id: "o1", id: "o1",
titolo: "90% di presenze ad agosto", titolo: "90% di presenze ad agosto",
descrizione: "Media presenze su partite e allenamenti del mese", descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenzeMese(ctx), valore: percentualePresenzeMese(ctx, rosa.length),
target: 90, target: 90,
unita: "%", unita: "%",
scadenza: "2026-08-31", scadenza: "2026-08-31",
@@ -75,7 +75,7 @@ export function obiettiviSquadra(
id: "o2", id: "o2",
titolo: "Tutti rispondono alle convocazioni", titolo: "Tutti rispondono alle convocazioni",
descrizione: "Percentuale di risposte date sugli eventi in programma", descrizione: "Percentuale di risposte date sugli eventi in programma",
valore: percentualeRisposte(ctx), valore: percentualeRisposte(ctx, rosa.length),
target: 90, target: 90,
unita: "%", unita: "%",
scadenza: "2026-09-30", scadenza: "2026-09-30",
@@ -169,8 +169,12 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
return Math.min(100, Math.round((o.valore / o.target) * 100)); return Math.min(100, Math.round((o.valore / o.target) * 100));
} }
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) { export function obiettiviOrdinati(
return obiettiviSquadra(rosa, ctx).sort((a, b) => { rosa?: Giocatore[],
ctx?: ContestoObiettivi,
storicoMatch?: Match[],
) {
return obiettiviSquadra(rosa, ctx, storicoMatch).sort((a, b) => {
const pa = progressoObiettivo(a); const pa = progressoObiettivo(a);
const pb = progressoObiettivo(b); const pb = progressoObiettivo(b);
const ca = pa >= 100 ? 1 : 0; const ca = pa >= 100 ? 1 : 0;
+3 -4
View File
@@ -1,4 +1,4 @@
import { giocatori } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
import type { Evento } from "./eventi"; import type { Evento } from "./eventi";
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null }; export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
@@ -18,6 +18,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
export function completaTurni( export function completaTurni(
turni: Record<string, string>, turni: Record<string, string>,
eventi: Evento[], eventi: Evento[],
giocatori: Giocatore[],
): Record<string, string> { ): Record<string, string> {
const risultato: Record<string, string> = { ...turni }; const risultato: Record<string, string> = { ...turni };
const conteggio = new Map<string, number>(giocatori.map((g) => [g.id, 0])); const conteggio = new Map<string, number>(giocatori.map((g) => [g.id, 0]));
@@ -32,9 +33,7 @@ export function completaTurni(
} }
if (assegnato) return; if (assegnato) return;
const scelto = giocatori const scelto = giocatori.slice().sort((a, b) => {
.slice()
.sort((a, b) => {
const ca = conteggio.get(a.id) ?? 0; const ca = conteggio.get(a.id) ?? 0;
const cb = conteggio.get(b.id) ?? 0; const cb = conteggio.get(b.id) ?? 0;
if (ca !== cb) return ca - cb; if (ca !== cb) return ca - cb;
+3 -1
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { completaTurni } from "./palloni-core"; import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi"; import { useEventi } from "./eventi";
import { useRosaBase } from "./rosa-base";
export const TURNI_KEY = ["turni-palloni"] as const; export const TURNI_KEY = ["turni-palloni"] as const;
@@ -26,8 +27,9 @@ export function useTurniPalloni() {
// Cambia raramente: una lettura per sessione è sufficiente. // Cambia raramente: una lettura per sessione è sufficiente.
const query = useQuery({ queryKey: TURNI_KEY, queryFn: fetchTurni, staleTime: 30 * 60_000 }); const query = useQuery({ queryKey: TURNI_KEY, queryFn: fetchTurni, staleTime: 30 * 60_000 });
const { eventi } = useEventi(); const { eventi } = useEventi();
const giocatori = useRosaBase();
const salvati = query.data ?? {}; const salvati = query.data ?? {};
return { ...query, salvati, turni: completaTurni(salvati, eventi) }; return { ...query, salvati, turni: completaTurni(salvati, eventi, giocatori) };
} }
export function useAssegnaTurno() { export function useAssegnaTurno() {
+4 -4
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useEventi } from "./eventi"; import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze"; import { useRispostePresenze } from "./presenze";
import { giocatori } from "./crapp-data"; import { useRosaBase } from "./rosa-base";
/** /**
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni. * Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
@@ -46,6 +46,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
> { > {
const { eventi } = useEventi(); const { eventi } = useEventi();
const { presenze } = useRispostePresenze(); const { presenze } = useRispostePresenze();
const giocatori = useRosaBase();
return useMemo(() => { return useMemo(() => {
const oggi = new Date(); const oggi = new Date();
@@ -60,8 +61,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {}; const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {};
for (const e of rilevanti) { for (const e of rilevanti) {
const ids = const ids = e.convocati.length > 0 ? e.convocati : giocatori.map((g) => g.id);
e.convocati.length > 0 ? e.convocati : giocatori.map((g) => g.id);
for (const id of ids) { for (const id of ids) {
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 }); const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
rec.totali += 1; rec.totali += 1;
@@ -73,5 +73,5 @@ export function usePresenzeUltimoMeseTutti(): Record<
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0; rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
} }
return out; return out;
}, [eventi, presenze]); }, [eventi, presenze, giocatori]);
} }
+62
View File
@@ -0,0 +1,62 @@
import { useQuery } from "@tanstack/react-query";
import { strapiFetch } from "./strapi-client";
import type { Giocatore } from "./crapp-data";
function inizialiDa(nome: string) {
return nome
.split(" ")
.map((p) => p[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
type GiocatoreStrapi = {
codice: string;
nome: string;
numero: number;
ruolo: string;
nascita: string;
};
/** Anagrafica base (senza statistiche, calcolate a parte da useRosa/giocatoriConScout). */
function daEntry(e: GiocatoreStrapi): Giocatore {
return {
id: e.codice,
nome: e.nome,
numero: e.numero,
ruolo: e.ruolo,
nascita: e.nascita,
iniziali: inizialiDa(e.nome),
totaliEventi: 0,
presenze: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
mvp: 0,
mediaVoto: 0,
palloni: 0,
cacche: 0,
cacchePartita: 0,
infortuni: 0,
ritardi: 0,
};
}
export const ROSA_BASE_KEY = ["rosa-base"] as const;
/** Usabile anche fuori da React (route server, script): stessa fonte dati dell'hook. */
export async function fetchRosaBase(): Promise<Giocatore[]> {
const entries = await strapiFetch<GiocatoreStrapi>("giocatoris?sort=numero");
return entries.map((e) => daEntry(e));
}
/** Anagrafica rosa dal CMS: cambia a stagione, non a sessione — cache lunga, niente polling. */
export function useRosaBaseQuery() {
return useQuery({ queryKey: ROSA_BASE_KEY, queryFn: fetchRosaBase, staleTime: 60 * 60_000 });
}
export function useRosaBase(): Giocatore[] {
return useRosaBaseQuery().data ?? [];
}
+8 -6
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { giocatori, type Giocatore } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
import { useRosaBase } from "./rosa-base";
import { giocatoriConScout, useScoutMatches } from "./scout-store"; import { giocatoriConScout, useScoutMatches } from "./scout-store";
import { useVotiMvp, vincitoriMvp } from "./mvp-voti"; import { useVotiMvp, vincitoriMvp } from "./mvp-voti";
import { mediePagelle, usePagelle } from "./pagelle"; import { mediePagelle, usePagelle } from "./pagelle";
@@ -11,6 +12,7 @@ import { useGiocatoreBase } from "./user-store";
import { useEventi } from "./eventi"; import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze"; import { useRispostePresenze } from "./presenze";
import { obiettiviOrdinati } from "./obiettivi"; import { obiettiviOrdinati } from "./obiettivi";
import { useStoricoMatch } from "./storico-match";
/** /**
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto, * Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
@@ -18,6 +20,7 @@ import { obiettiviOrdinati } from "./obiettivi";
* nessuna query aggiuntiva rispetto a quelle che l'app fa comunque. * nessuna query aggiuntiva rispetto a quelle che l'app fa comunque.
*/ */
export function useRosa(): Giocatore[] { export function useRosa(): Giocatore[] {
const base = useRosaBase();
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const voti = useVotiMvp(); const voti = useVotiMvp();
const { voti: pagelle } = usePagelle(); const { voti: pagelle } = usePagelle();
@@ -31,7 +34,7 @@ export function useRosa(): Giocatore[] {
const medie = mediePagelle(pagelle); const medie = mediePagelle(pagelle);
const statCacche = statisticheCacche(cacche); const statCacche = statisticheCacche(cacche);
const palloni = conteggioTurni(turni); const palloni = conteggioTurni(turni);
return giocatoriConScout(scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({ return giocatoriConScout(base, scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({
...g, ...g,
mediaVoto: medie[g.id]?.media ?? g.mediaVoto, mediaVoto: medie[g.id]?.media ?? g.mediaVoto,
palloni: palloni[g.id] ?? 0, palloni: palloni[g.id] ?? 0,
@@ -40,7 +43,7 @@ export function useRosa(): Giocatore[] {
infortuni: infortuni[g.id] ?? 0, infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0, ritardi: ritardi[g.id] ?? 0,
})); }));
}, [scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]); }, [base, scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]);
} }
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */ /** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
@@ -57,7 +60,6 @@ export function useObiettivi() {
const { eventi } = useEventi(); const { eventi } = useEventi();
const { presenze } = useRispostePresenze(); const { presenze } = useRispostePresenze();
const { voti: pagelle } = usePagelle(); const { voti: pagelle } = usePagelle();
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle }); const storicoMatch = useStoricoMatch();
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle }, storicoMatch);
} }
export { giocatori };
+10 -3
View File
@@ -1,4 +1,4 @@
import { giocatori } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
function riga(campi: Array<string | number>) { function riga(campi: Array<string | number>) {
@@ -11,9 +11,16 @@ function riga(campi: Array<string | number>) {
} }
/** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */ /** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */
export function csvScoutMatch(match: ScoutMatch): string { export function csvScoutMatch(giocatori: Giocatore[], match: ScoutMatch): string {
const righe: string[] = []; const righe: string[] = [];
righe.push(riga(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"])); 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(riga(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
righe.push(""); righe.push("");
righe.push(riga(["Set", "Parziale nostro", "Parziale loro"])); righe.push(riga(["Set", "Parziale nostro", "Parziale loro"]));
+53 -19
View File
@@ -1,24 +1,54 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { giocatori, classifica, type Giocatore, type RigaClassifica } from "./crapp-data"; import type { Giocatore, RigaClassifica } from "./crapp-data";
export type AzioneTipo = export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
| "attacco"
| "ace"
| "muro"
| "errore"
| "punto_avv"
| "errore_avv";
export const azioniMeta: Record< export const azioniMeta: Record<
AzioneTipo, AzioneTipo,
{ label: string; short: string; nostro: boolean; richiedeGiocatore: boolean; className: string } { label: string; short: string; nostro: boolean; richiedeGiocatore: boolean; className: string }
> = { > = {
attacco: { label: "Punto attacco", short: "Punto", nostro: true, richiedeGiocatore: true, className: "bg-accent text-accent-foreground" }, attacco: {
ace: { label: "Ace", short: "Ace", nostro: true, richiedeGiocatore: true, className: "bg-success text-success-foreground" }, label: "Punto attacco",
muro: { label: "Muro", short: "Muro", nostro: true, richiedeGiocatore: true, className: "bg-info text-info-foreground" }, short: "Punto",
errore: { label: "Errore nostro", short: "Errore", nostro: false, richiedeGiocatore: true, className: "bg-destructive text-destructive-foreground" }, nostro: true,
punto_avv: { label: "Punto avversario", short: "Punto avv.", nostro: false, richiedeGiocatore: false, className: "bg-muted text-muted-foreground" }, richiedeGiocatore: true,
errore_avv: { label: "Errore avversario", short: "Err. avv.", nostro: true, richiedeGiocatore: false, className: "bg-secondary text-foreground" }, className: "bg-accent text-accent-foreground",
},
ace: {
label: "Ace",
short: "Ace",
nostro: true,
richiedeGiocatore: true,
className: "bg-success text-success-foreground",
},
muro: {
label: "Muro",
short: "Muro",
nostro: true,
richiedeGiocatore: true,
className: "bg-info text-info-foreground",
},
errore: {
label: "Errore nostro",
short: "Errore",
nostro: false,
richiedeGiocatore: true,
className: "bg-destructive text-destructive-foreground",
},
punto_avv: {
label: "Punto avversario",
short: "Punto avv.",
nostro: false,
richiedeGiocatore: false,
className: "bg-muted text-muted-foreground",
},
errore_avv: {
label: "Errore avversario",
short: "Err. avv.",
nostro: true,
richiedeGiocatore: false,
className: "bg-secondary text-foreground",
},
}; };
export type Azione = { export type Azione = {
@@ -125,10 +155,11 @@ export function totaliSquadra(matches: ScoutMatch[]) {
/** Presenze e MVP ricavati dalle partite scoutate: nessuna statistica individuale offensiva. /** Presenze e MVP ricavati dalle partite scoutate: nessuna statistica individuale offensiva.
* `mvpPerMatch` mappa idPartita -> nome dell'MVP eletto dalla squadra. */ * `mvpPerMatch` mappa idPartita -> nome dell'MVP eletto dalla squadra. */
export function giocatoriConScout( export function giocatoriConScout(
baseGiocatori: Giocatore[],
matches: ScoutMatch[], matches: ScoutMatch[],
mvpPerMatch: Record<string, string> = {}, mvpPerMatch: Record<string, string> = {},
): Giocatore[] { ): Giocatore[] {
return giocatori.map((g) => { return baseGiocatori.map((g) => {
let presenze = 0; let presenze = 0;
let mvp = 0; let mvp = 0;
for (const m of matches) { for (const m of matches) {
@@ -144,9 +175,12 @@ export function giocatoriConScout(
}); });
} }
/** Classifica demo aggiornata con i match scoutati (solo la nostra riga). */ /** Classifica CSI aggiornata con i match scoutati (solo la nostra riga). */
export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] { export function classificaConScout(
if (matches.length === 0) return classifica; classificaBase: RigaClassifica[],
matches: ScoutMatch[],
): RigaClassifica[] {
if (matches.length === 0) return classificaBase;
const agg = matches.reduce( const agg = matches.reduce(
(s, m) => { (s, m) => {
const vinta = m.setNostri > m.setLoro; const vinta = m.setNostri > m.setLoro;
@@ -160,7 +194,7 @@ export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
}, },
{ giocate: 0, vinte: 0, perse: 0, setFatti: 0, setSubiti: 0, punti: 0 }, { giocate: 0, vinte: 0, perse: 0, setFatti: 0, setSubiti: 0, punti: 0 },
); );
return classifica return classificaBase
.map((r) => .map((r) =>
r.squadra === "CRAP Volley" r.squadra === "CRAP Volley"
? { ? {
+43
View File
@@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import { strapiFetch } from "./strapi-client";
import type { Match } from "./crapp-data";
type MatchStoricoStrapi = {
data: string;
avversario: string;
casa: boolean;
set_nostri: number;
set_loro: number;
parziali: Array<[number, number]> | null;
mvp: string | null;
};
function daEntry(e: MatchStoricoStrapi & { id: number }): Match {
return {
id: `m${e.id}`,
data: e.data,
avversario: e.avversario,
casa: e.casa,
setNostri: e.set_nostri,
setLoro: e.set_loro,
parziali: e.parziali ?? [],
mvp: e.mvp ?? "",
};
}
export const STORICO_MATCH_KEY = ["storico-match"] as const;
async function fetchStoricoMatch(): Promise<Match[]> {
const entries = await strapiFetch<MatchStoricoStrapi>("match-storicos?sort=data:desc");
return entries.map(daEntry);
}
/** Storico partite campionato CSI dal CMS: cambia a partita giocata, non a sessione. */
export function useStoricoMatch(): Match[] {
const query = useQuery({
queryKey: STORICO_MATCH_KEY,
queryFn: fetchStoricoMatch,
staleTime: 60 * 60_000,
});
return query.data ?? [];
}
+22
View File
@@ -0,0 +1,22 @@
// Client di sola lettura per il CMS Strapi (rosa, classifica, storico partite — vedi
// infra/strapi/). Niente SDK: l'API REST di Strapi è semplice query/JSON, un wrapper fetch basta.
// I content type letti qui hanno il ruolo "Public" abilitato in lettura (find/findOne), stesso
// pattern delle tabelle Supabase "open RLS" già usate nel progetto — nessun token da gestire
// lato client per queste letture.
function strapiUrl(): string {
const url = import.meta.env["VITE_STRAPI_URL"] || process.env["VITE_STRAPI_URL"];
if (!url) throw new Error("Manca la variabile d'ambiente VITE_STRAPI_URL.");
return url;
}
type StrapiEntry<T> = T & { id: number };
type StrapiListResponse<T> = { data: StrapiEntry<T>[] };
/** GET su un content type Strapi (path relativo, es. "giocatoris?sort=numero"). */
export async function strapiFetch<T>(path: string): Promise<StrapiEntry<T>[]> {
const res = await fetch(`${strapiUrl()}/api/${path}`);
if (!res.ok) throw new Error(`Strapi ${path}: ${res.status}`);
const json = (await res.json()) as StrapiListResponse<T>;
return json.data;
}
+5 -3
View File
@@ -1,5 +1,6 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { giocatori, type Giocatore } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
import { useRosaBase } from "./rosa-base";
import { conInfortuni, useInfortuniERitardi } from "./infortuni"; import { conInfortuni, useInfortuniERitardi } from "./infortuni";
const KEY = "crapp-user-v1"; const KEY = "crapp-user-v1";
@@ -36,7 +37,7 @@ export function resetGiocatore() {
write(null); write(null);
} }
/** Solo lettura locale: nessuna dipendenza da React Query (usabile fuori dal provider). */ /** Richiede un ancestor QueryClientProvider (la rosa base arriva dal CMS Strapi). */
export function useGiocatoreBase(): Giocatore | null { export function useGiocatoreBase(): Giocatore | null {
const id = useSyncExternalStore( const id = useSyncExternalStore(
(cb) => { (cb) => {
@@ -46,7 +47,8 @@ export function useGiocatoreBase(): Giocatore | null {
() => read(), () => read(),
() => null, () => null,
); );
return id ? (giocatori.find((x) => x.id === id) ?? null) : null; const rosa = useRosaBase();
return id ? (rosa.find((x) => x.id === id) ?? null) : null;
} }
export function useGiocatoreCorrente(): Giocatore | null { export function useGiocatoreCorrente(): Giocatore | null {
+21
View File
@@ -23,6 +23,7 @@ import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/pub
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio' import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio'
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe' import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
import { Route as ApiPublicScoutFinaleRouteImport } from './routes/api/public/scout-finale'
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze' import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
@@ -96,6 +97,11 @@ const ApiPublicPushSubscribeRoute = ApiPublicPushSubscribeRouteImport.update({
path: '/api/public/push-subscribe', path: '/api/public/push-subscribe',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiPublicScoutFinaleRoute = ApiPublicScoutFinaleRouteImport.update({
id: '/api/public/scout-finale',
path: '/api/public/scout-finale',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicSollecitaPresenzeRoute = const ApiPublicSollecitaPresenzeRoute =
ApiPublicSollecitaPresenzeRouteImport.update({ ApiPublicSollecitaPresenzeRouteImport.update({
id: '/api/public/sollecita-presenze', id: '/api/public/sollecita-presenze',
@@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
'/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/scout-finale': typeof ApiPublicScoutFinaleRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -135,6 +142,7 @@ export interface FileRoutesByTo {
'/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/scout-finale': typeof ApiPublicScoutFinaleRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -153,6 +161,7 @@ export interface FileRoutesById {
'/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/scout-finale': typeof ApiPublicScoutFinaleRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -172,6 +181,7 @@ export interface FileRouteTypes {
| '/api/public/push-config' | '/api/public/push-config'
| '/api/public/push-messaggio' | '/api/public/push-messaggio'
| '/api/public/push-subscribe' | '/api/public/push-subscribe'
| '/api/public/scout-finale'
| '/api/public/sollecita-presenze' | '/api/public/sollecita-presenze'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@@ -189,6 +199,7 @@ export interface FileRouteTypes {
| '/api/public/push-config' | '/api/public/push-config'
| '/api/public/push-messaggio' | '/api/public/push-messaggio'
| '/api/public/push-subscribe' | '/api/public/push-subscribe'
| '/api/public/scout-finale'
| '/api/public/sollecita-presenze' | '/api/public/sollecita-presenze'
id: id:
| '__root__' | '__root__'
@@ -206,6 +217,7 @@ export interface FileRouteTypes {
| '/api/public/push-config' | '/api/public/push-config'
| '/api/public/push-messaggio' | '/api/public/push-messaggio'
| '/api/public/push-subscribe' | '/api/public/push-subscribe'
| '/api/public/scout-finale'
| '/api/public/sollecita-presenze' | '/api/public/sollecita-presenze'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -224,6 +236,7 @@ export interface RootRouteChildren {
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
ApiPublicScoutFinaleRoute: typeof ApiPublicScoutFinaleRoute
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
} }
@@ -327,6 +340,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicPushSubscribeRouteImport preLoaderRoute: typeof ApiPublicPushSubscribeRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/public/scout-finale': {
id: '/api/public/scout-finale'
path: '/api/public/scout-finale'
fullPath: '/api/public/scout-finale'
preLoaderRoute: typeof ApiPublicScoutFinaleRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/sollecita-presenze': { '/api/public/sollecita-presenze': {
id: '/api/public/sollecita-presenze' id: '/api/public/sollecita-presenze'
path: '/api/public/sollecita-presenze' path: '/api/public/sollecita-presenze'
@@ -352,6 +372,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute, ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute,
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute, ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
ApiPublicScoutFinaleRoute: ApiPublicScoutFinaleRoute,
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute, ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
+37 -8
View File
@@ -18,6 +18,7 @@ import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { TeamLogo } from "../components/crapp/ui-bits"; import { TeamLogo } from "../components/crapp/ui-bits";
import { useGiocatoreBase } from "../lib/user-store"; import { useGiocatoreBase } from "../lib/user-store";
import { useRosaBaseQuery } from "../lib/rosa-base";
function NotFoundComponent() { function NotFoundComponent() {
return ( return (
@@ -87,22 +88,36 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
{ title: "CrAPP — L'app del CRAP Volley" }, { title: "CrAPP — L'app del CRAP Volley" },
{ {
name: "description", name: "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.",
}, },
{ name: "author", content: "CRAP Volley" }, { name: "author", content: "CRAP Volley" },
{ name: "theme-color", content: "#111111" }, { name: "theme-color", content: "#111111" },
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" }, { property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
{ {
property: "og:description", 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.",
}, },
{ property: "og:type", content: "website" }, { property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" }, { name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:site", content: "@Lovable" }, { name: "twitter:site", content: "@Lovable" },
{ name: "twitter:title", content: "CrAPP — L'app del CRAP Volley" }, { name: "twitter:title", content: "CrAPP — L'app del CRAP Volley" },
{ name: "twitter:description", content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile." }, {
{ property: "og:image", content: "https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png" }, name: "twitter:description",
{ name: "twitter:image", content: "https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png" }, content:
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
},
{
property: "og:image",
content:
"https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png",
},
{
name: "twitter:image",
content:
"https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png",
},
], ],
links: [ links: [
{ {
@@ -142,18 +157,32 @@ function RootShell({ children }: { children: ReactNode }) {
function RootComponent() { function RootComponent() {
const { queryClient } = Route.useRouteContext(); const { queryClient } = Route.useRouteContext();
// La rosa base (nome/id giocatore) arriva dal CMS Strapi via TanStack Query: il provider deve
// avvolgere anche RootBody, che la legge tramite useGiocatoreBase.
return (
<QueryClientProvider client={queryClient}>
<RootBody />
</QueryClientProvider>
);
}
function RootBody() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const giocatore = useGiocatoreBase(); const giocatore = useGiocatoreBase();
const rosaQuery = useRosaBaseQuery();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const isBenvenuto = location.pathname === "/benvenuto"; const isBenvenuto = location.pathname === "/benvenuto";
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
// Aspetta il primo caricamento della rosa base: prima che arrivi, "nessun giocatore
// risolto" non significa "nessuna selezione salvata".
if (rosaQuery.isPending) return;
if (!giocatore && !isBenvenuto) { if (!giocatore && !isBenvenuto) {
navigate({ to: "/benvenuto" }); navigate({ to: "/benvenuto" });
} }
}, [giocatore, isBenvenuto, navigate]); }, [giocatore, isBenvenuto, navigate, rosaQuery.isPending]);
if (!mounted) { if (!mounted) {
return ( return (
@@ -164,7 +193,7 @@ function RootComponent() {
} }
return ( return (
<QueryClientProvider client={queryClient}> <>
<div className="mx-auto min-h-screen max-w-md bg-background pb-24"> <div className="mx-auto min-h-screen max-w-md bg-background pb-24">
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */} {/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
<Outlet /> <Outlet />
@@ -172,6 +201,6 @@ function RootComponent() {
{!isBenvenuto && <BottomNav />} {!isBenvenuto && <BottomNav />}
{!isBenvenuto && <CelebrazioneBadge />} {!isBenvenuto && <CelebrazioneBadge />}
<Toaster position="top-center" duration={3500} closeButton /> <Toaster position="top-center" duration={3500} closeButton />
</QueryClientProvider> </>
); );
} }
+12 -4
View File
@@ -1,11 +1,12 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, MapPin, Clock, Users, Dumbbell, CalendarDays } from "lucide-react"; import { ArrowLeft, MapPin, Clock, Users, Dumbbell, CalendarDays } from "lucide-react";
import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData, giocatori } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi"; import { convocatiEvento, useEvento } from "@/lib/eventi";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni"; import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
import { RosaPresenze } from "@/components/crapp/RosaPresenze"; import { RosaPresenze } from "@/components/crapp/RosaPresenze";
import { usePresenzeEvento } from "@/lib/presenze"; import { usePresenzeEvento } from "@/lib/presenze";
import { useRosaBase } from "@/lib/rosa-base";
export const Route = createFileRoute("/allenamento/$id")({ export const Route = createFileRoute("/allenamento/$id")({
head: () => { head: () => {
@@ -13,9 +14,15 @@ export const Route = createFileRoute("/allenamento/$id")({
return { return {
meta: [ meta: [
{ title: `${titolo} — CrAPP` }, { title: `${titolo} — CrAPP` },
{ name: "description", content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley." }, {
name: "description",
content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley.",
},
{ property: "og:title", content: `${titolo} — CrAPP` }, { property: "og:title", content: `${titolo} — CrAPP` },
{ property: "og:description", content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley." }, {
property: "og:description",
content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley.",
},
{ property: "og:type", content: "website" }, { property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" }, { name: "twitter:card", content: "summary" },
], ],
@@ -28,6 +35,7 @@ function AllenamentoDetail() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { evento } = useEvento(id); const { evento } = useEvento(id);
const { risposte } = usePresenzeEvento(id); const { risposte } = usePresenzeEvento(id);
const giocatori = useRosaBase();
const presentiVeri = giocatori.filter( const presentiVeri = giocatori.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
).length; ).length;
@@ -84,7 +92,7 @@ function AllenamentoDetail() {
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold"> <div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento).length} Conferme: {presentiVeri}/{convocatiEvento(giocatori, evento).length}
</div> </div>
<TurnoPalloni eventoId={evento.id} /> <TurnoPalloni eventoId={evento.id} />
+3 -1
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { completaTurni, eventiPalloni, eventoPrecedente, oggiISO } from "@/lib/palloni-core"; import { completaTurni, eventiPalloni, eventoPrecedente, oggiISO } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server"; import { inviaPush } from "@/lib/webpush.server";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { fetchRosaBase } from "@/lib/rosa-base";
export const Route = createFileRoute("/api/public/promemoria-palloni")({ export const Route = createFileRoute("/api/public/promemoria-palloni")({
server: { server: {
@@ -15,7 +16,8 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
const salvati: Record<string, string> = {}; const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id; for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi(); const eventi = await leggiEventi();
const turni = completaTurni(salvati, eventi); const giocatori = await fetchRosaBase();
const turni = completaTurni(salvati, eventi, giocatori);
const oggi = oggiISO(); const oggi = oggiISO();
const destinatari = new Set<string>(); const destinatari = new Set<string>();
+9 -4
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { formatData, giocatori } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { import {
completaTurni, completaTurni,
eventiPalloni, eventiPalloni,
@@ -9,6 +9,7 @@ import {
oggiISO, oggiISO,
} from "@/lib/palloni-core"; } from "@/lib/palloni-core";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { fetchRosaBase } from "@/lib/rosa-base";
const schema = z.object({ endpoint: z.string().url().max(1000) }); const schema = z.object({ endpoint: z.string().url().max(1000) });
@@ -40,7 +41,8 @@ export const Route = createFileRoute("/api/public/push-messaggio")({
.eq("endpoint", parsed.data.endpoint) .eq("endpoint", parsed.data.endpoint)
.maybeSingle(); .maybeSingle();
if (!iscrizione) return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." }); if (!iscrizione)
return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
const { data: righe } = await supabaseAdmin const { data: righe } = await supabaseAdmin
.from("turni_palloni") .from("turni_palloni")
@@ -48,7 +50,8 @@ export const Route = createFileRoute("/api/public/push-messaggio")({
const salvati: Record<string, string> = {}; const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id; for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi(); const eventi = await leggiEventi();
const turni = completaTurni(salvati, eventi); const giocatori = await fetchRosaBase();
const turni = completaTurni(salvati, eventi, giocatori);
const oggi = oggiISO(); const oggi = oggiISO();
const mioId = iscrizione.giocatore_id; const mioId = iscrizione.giocatore_id;
@@ -76,7 +79,9 @@ export const Route = createFileRoute("/api/public/push-messaggio")({
return Response.json({ return Response.json({
title: "CrAPP · Turno palloni", title: "CrAPP · Turno palloni",
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.", body: nome
? `${nome}, controlla il turno palloni nel calendario.`
: "Controlla il calendario.",
}); });
}, },
}, },
+74
View File
@@ -0,0 +1,74 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const schema = z.object({
id: z.string().min(1).max(50),
data: z.string().min(1).max(20),
avversario: z.string().min(1).max(80),
casa: z.boolean(),
setNostri: z.number().int().min(0),
setLoro: z.number().int().min(0),
parziali: z.array(z.tuple([z.number().int(), z.number().int()])),
mvp: z.string().max(80),
azioni: z.array(
z.object({
id: z.string(),
tipo: z.enum(["attacco", "ace", "muro", "errore", "punto_avv", "errore_avv"]),
giocatoreId: z.string().optional(),
set: z.number().int(),
ts: z.number(),
}),
),
});
/**
* Proxy verso Strapi per il risultato finale di una partita scoutata: additivo, il localStorage
* del device resta la fonte usata da useRosa/giocatoriConScout. Best-effort: nessun retry, nessun
* upsert (un doppio invio crea al più una entry doppia in Strapi, da cancellare a mano).
*/
export const Route = createFileRoute("/api/public/scout-finale")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const strapiUrl = process.env["VITE_STRAPI_URL"];
const token = process.env["STRAPI_WRITE_TOKEN"];
if (!strapiUrl || !token) {
console.error("scout-finale: STRAPI_URL o STRAPI_WRITE_TOKEN mancanti");
return new Response("CMS non configurato", { status: 503 });
}
const m = parsed.data;
const res = await fetch(`${strapiUrl}/api/scout-match-finales`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
match_id: m.id,
data: m.data,
avversario: m.avversario,
casa: m.casa,
set_nostri: m.setNostri,
set_loro: m.setLoro,
parziali: m.parziali,
mvp: m.mvp,
azioni: m.azioni,
},
}),
});
if (!res.ok) {
console.error("scout-finale: Strapi ha risposto", res.status, await res.text());
return new Response("Salvataggio su CMS non riuscito", { status: 502 });
}
return Response.json({ ok: true });
},
},
},
});
+3 -1
View File
@@ -1,8 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { formatData, giocatori } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server"; import { inviaPush } from "@/lib/webpush.server";
import { fetchRosaBase } from "@/lib/rosa-base";
const schema = z.object({ const schema = z.object({
eventoId: z.string().min(1).max(50), eventoId: z.string().min(1).max(50),
@@ -28,6 +29,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
.eq("evento_id", evento.id); .eq("evento_id", evento.id);
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato])); const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
const giocatori = await fetchRosaBase();
const destinatari = giocatori const destinatari = giocatori
.filter((g) => { .filter((g) => {
const stato = stati.get(g.id); const stato = stati.get(g.id);
+2 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { TeamLogo } from "@/components/crapp/ui-bits"; import { TeamLogo } from "@/components/crapp/ui-bits";
import { giocatori } from "@/lib/crapp-data"; import { useRosaBase } from "@/lib/rosa-base";
import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store"; import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
export const Route = createFileRoute("/benvenuto")({ export const Route = createFileRoute("/benvenuto")({
@@ -25,6 +25,7 @@ export const Route = createFileRoute("/benvenuto")({
function Benvenuto() { function Benvenuto() {
const navigate = useNavigate(); const navigate = useNavigate();
const giocatore = useGiocatoreCorrente(); const giocatore = useGiocatoreCorrente();
const giocatori = useRosaBase();
useEffect(() => { useEffect(() => {
if (giocatore) { if (giocatore) {
+12 -5
View File
@@ -8,6 +8,7 @@ import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { isAdmin } from "@/lib/crapp-data"; import { isAdmin } from "@/lib/crapp-data";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { useRosaBase } from "@/lib/rosa-base";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -97,12 +98,13 @@ function Calendario() {
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null); const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false); const [drawerAperto, setDrawerAperto] = useState(false);
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const { eventi } = useEventi(); const { eventi } = useEventi();
const { anno, mese, precedente, successivo } = useMeseNav(); const { anno, mese, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese); const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
const mesePrefix = `${anno}-${pad2(mese + 1)}`; const mesePrefix = `${anno}-${pad2(mese + 1)}`;
const compleanni = compleanniEventi(anno); const compleanni = compleanniEventi(giocatori, anno);
const compleanniMese = compleanni.filter((c) => c.data.startsWith(mesePrefix)); const compleanniMese = compleanni.filter((c) => c.data.startsWith(mesePrefix));
const eventiMese = eventi.filter((e) => e.data.startsWith(mesePrefix)); const eventiMese = eventi.filter((e) => e.data.startsWith(mesePrefix));
@@ -120,7 +122,9 @@ function Calendario() {
setDrawerAperto(true); setDrawerAperto(true);
} }
const eventiGiornoSelezionato = giornoSelezionato ? eventiPerGiorno.get(giornoSelezionato) ?? [] : []; const eventiGiornoSelezionato = giornoSelezionato
? (eventiPerGiorno.get(giornoSelezionato) ?? [])
: [];
return ( return (
<> <>
@@ -200,7 +204,8 @@ function Calendario() {
.join(", ")})` .join(", ")})`
: undefined; : undefined;
const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined; const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined;
const isOggi = !!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno; const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
const Cella = haEventi ? "button" : "div"; const Cella = haEventi ? "button" : "div";
return ( return (
<Cella <Cella
@@ -222,7 +227,9 @@ function Calendario() {
aria-label={haEventi ? `Eventi del ${giorno}` : undefined} aria-label={haEventi ? `Eventi del ${giorno}` : undefined}
aria-current={isOggi ? "date" : undefined} aria-current={isOggi ? "date" : undefined}
> >
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">{giorno}</span> <span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">
{giorno}
</span>
</Cella> </Cella>
); );
})} })}
@@ -245,7 +252,7 @@ function Calendario() {
</Section> </Section>
) : null} ) : null}
{io && isAdmin(io.id) ? ( {io && isAdmin(io) ? (
<div className="px-5 pt-4"> <div className="px-5 pt-4">
<Link <Link
to="/eventi" to="/eventi"
+6 -3
View File
@@ -2,7 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { storicoMatch } from "@/lib/crapp-data"; import { useClassificaCsi } from "@/lib/classifica-csi";
import { useStoricoMatch } from "@/lib/storico-match";
import { classificaConScout, useScoutMatches } from "@/lib/scout-store"; import { classificaConScout, useScoutMatches } from "@/lib/scout-store";
import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { ScoutEntry } from "@/components/crapp/ScoutEntry";
@@ -22,8 +23,10 @@ export const Route = createFileRoute("/classifica")({
}); });
function Classifica() { function Classifica() {
const classificaBase = useClassificaCsi();
const storicoMatch = useStoricoMatch();
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const classifica = classificaConScout(scoutMatches); const classifica = classificaConScout(classificaBase, scoutMatches);
const risultati = [ const risultati = [
...scoutMatches.map((m) => ({ ...scoutMatches.map((m) => ({
id: m.id, id: m.id,
@@ -41,7 +44,7 @@ function Classifica() {
<div className="px-5 pt-4"> <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"> <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" /> <RefreshCw className="h-3.5 w-3.5 text-accent" />
Dati CSI aggiornati oggi alle 08:40 (demo) Dati CSI gestiti da chi cura la classifica
</div> </div>
<div className="mt-2"> <div className="mt-2">
<ScoutEntry variante="compatto" /> <ScoutEntry variante="compatto" />
+14 -5
View File
@@ -4,7 +4,8 @@ import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData, giocatori, isAdmin } from "@/lib/crapp-data"; import { formatData, isAdmin } from "@/lib/crapp-data";
import { useRosaBase } from "@/lib/rosa-base";
import { import {
categoriaEvento, categoriaEvento,
daCategoria, daCategoria,
@@ -47,12 +48,13 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
function GestioneEventi() { function GestioneEventi() {
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const { eventi, isPending } = useEventi(); const { eventi, isPending } = useEventi();
const salva = useSalvaEvento(); const salva = useSalvaEvento();
const elimina = useEliminaEvento(); const elimina = useEliminaEvento();
const [bozza, setBozza] = useState<Evento | null>(null); const [bozza, setBozza] = useState<Evento | null>(null);
if (!io || !isAdmin(io.id)) { if (!io || !isAdmin(io)) {
return ( return (
<> <>
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" /> <PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
@@ -118,7 +120,9 @@ function GestioneEventi() {
</div> </div>
{bozza ? ( {bozza ? (
<Section titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}> <Section
titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}
>
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card"> <div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="grid grid-cols-4 gap-1 rounded-full bg-secondary p-1"> <div className="grid grid-cols-4 gap-1 rounded-full bg-secondary p-1">
{tipi.map((t) => ( {tipi.map((t) => (
@@ -239,7 +243,9 @@ function GestioneEventi() {
} }
className={cn( className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors", "truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
scelto ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground", scelto
? "bg-accent text-accent-foreground"
: "bg-secondary text-muted-foreground",
)} )}
> >
{g.nome} {g.nome}
@@ -276,7 +282,10 @@ function GestioneEventi() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{eventi.map((e) => ( {eventi.map((e) => (
<div key={e.id} className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"> <div
key={e.id}
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p> <p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
+20 -8
View File
@@ -7,7 +7,8 @@ import { Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra"; import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero"; import { Numero } from "@/components/motion/Numero";
import { classifica, storicoMatch } from "@/lib/crapp-data"; import { useClassificaCsi } from "@/lib/classifica-csi";
import { useStoricoMatch } from "@/lib/storico-match";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi"; import { useEventi, type Evento } from "@/lib/eventi";
import { useIo, useObiettivi } from "@/lib/rosa"; import { useIo, useObiettivi } from "@/lib/rosa";
@@ -24,7 +25,8 @@ export const Route = createFileRoute("/")({
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" }, { property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
{ {
property: "og:description", 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.",
}, },
], ],
}), }),
@@ -33,13 +35,15 @@ export const Route = createFileRoute("/")({
function Index() { function Index() {
const giocatore = useIo(); const giocatore = useIo();
const classifica = useClassificaCsi();
const storicoMatch = useStoricoMatch();
const { eventi } = useEventi(); const { eventi } = useEventi();
const oggi = new Date().toISOString().slice(0, 10); const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3); const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null; const prossimo = prossimi[0] ?? null;
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null; const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const noi = classifica.find((r) => r.squadra === "CRAP Volley")!; const noi = classifica.find((r) => r.squadra === "CRAP Volley") ?? null;
const ultima = storicoMatch[0]!; const ultima = storicoMatch[0] ?? null;
const obiettivi = useObiettivi(); const obiettivi = useObiettivi();
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null; const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
@@ -58,6 +62,7 @@ function Index() {
<TeamLogo className="h-12 w-12" /> <TeamLogo className="h-12 w-12" />
</div> </div>
{noi ? (
<div className="mt-6 grid grid-cols-3 gap-2 text-center"> <div className="mt-6 grid grid-cols-3 gap-2 text-center">
<div className="rounded-2xl bg-primary-foreground/10 p-3"> <div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">{noi.pos}º</p> <p className="font-display text-2xl leading-none">{noi.pos}º</p>
@@ -77,6 +82,7 @@ function Index() {
<p className="text-[10px] uppercase text-primary-foreground/60">Streak</p> <p className="text-[10px] uppercase text-primary-foreground/60">Streak</p>
</div> </div>
</div> </div>
) : null}
</Reveal> </Reveal>
<PromemoriaPalloni /> <PromemoriaPalloni />
@@ -114,6 +120,7 @@ function Index() {
</div> </div>
</Section> </Section>
{ultima ? (
<Section <Section
titolo="Ultima partita" titolo="Ultima partita"
indice={3} indice={3}
@@ -150,12 +157,16 @@ function Index() {
</div> </div>
</div> </div>
</Section> </Section>
) : null}
<Section <Section
titolo="Obiettivo di squadra" titolo="Obiettivo di squadra"
indice={4} indice={4}
azione={ 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" /> Tutti <ChevronRight className="h-3 w-3" />
</Link> </Link>
} }
@@ -168,10 +179,11 @@ function Index() {
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" /> <Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> {" "} Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> {" "}
{obiettivo.valore}/{obiettivo.target}{" "} {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
{obiettivo.unita}. </p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p> </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> <p className="mt-1 text-[11px] text-muted-foreground">{obiettivo.impatto}</p>
</div> </div>
) : null} ) : null}
+31 -10
View File
@@ -2,13 +2,15 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react"; import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { storicoMatch, formatData, giocatori, isAdmin } from "@/lib/crapp-data"; import { formatData, isAdmin } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi"; import { convocatiEvento, useEvento } from "@/lib/eventi";
import { Pagelle } from "@/components/crapp/Pagelle"; import { Pagelle } from "@/components/crapp/Pagelle";
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { useRosaBase } from "@/lib/rosa-base";
import { useStoricoMatch } from "@/lib/storico-match";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
import { VotoSocial } from "@/components/crapp/VotoSocial"; import { VotoSocial } from "@/components/crapp/VotoSocial";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni"; import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
@@ -21,9 +23,15 @@ export const Route = createFileRoute("/partita/$id")({
return { return {
meta: [ meta: [
{ title: `${titolo} — CrAPP` }, { title: `${titolo} — CrAPP` },
{ name: "description", content: "Dettaglio partita, formazione e risultati del CRAP Volley." }, {
name: "description",
content: "Dettaglio partita, formazione e risultati del CRAP Volley.",
},
{ property: "og:title", content: `${titolo} — CrAPP` }, { property: "og:title", content: `${titolo} — CrAPP` },
{ property: "og:description", content: "Dettaglio partita, formazione e risultati del CRAP Volley." }, {
property: "og:description",
content: "Dettaglio partita, formazione e risultati del CRAP Volley.",
},
{ property: "og:type", content: "website" }, { property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" }, { name: "twitter:card", content: "summary" },
], ],
@@ -36,6 +44,8 @@ function PartitaDetail() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { evento } = useEvento(id); const { evento } = useEvento(id);
const io = useGiocatoreCorrente(); const io = useGiocatoreCorrente();
const giocatori = useRosaBase();
const storicoMatch = useStoricoMatch();
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const { risposte } = usePresenzeEvento(id); const { risposte } = usePresenzeEvento(id);
const presentiVeri = giocatori.filter( const presentiVeri = giocatori.filter(
@@ -56,7 +66,7 @@ function PartitaDetail() {
); );
} }
const convocati = convocatiEvento(evento); const convocati = convocatiEvento(giocatori, evento);
const scout = scoutMatches.find((m) => m.id === evento.id || m.data === evento.data) ?? null; const scout = scoutMatches.find((m) => m.id === evento.id || m.data === evento.data) ?? null;
const match = scout const match = scout
? { ? {
@@ -71,7 +81,7 @@ function PartitaDetail() {
: storicoMatch.find((m) => m.data === evento.data); : storicoMatch.find((m) => m.data === evento.data);
const totaliTeam = scout ? totaliSquadra([scout]) : null; const totaliTeam = scout ? totaliSquadra([scout]) : null;
const avversario = evento.titolo.includes(" vs ") const avversario = evento.titolo.includes(" vs ")
? evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo ? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
: evento.titolo; : evento.titolo;
const casa = evento.casa; const casa = evento.casa;
const vinta = match && match.setNostri > match.setLoro; const vinta = match && match.setNostri > match.setLoro;
@@ -138,7 +148,9 @@ function PartitaDetail() {
<span <span
className={cn( className={cn(
"rounded-full px-3 py-1 text-lg font-display font-bold", "rounded-full px-3 py-1 text-lg font-display font-bold",
vinta ? "bg-success text-success-foreground" : "bg-destructive text-destructive-foreground", vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)} )}
> >
{match.setNostri} - {match.setLoro} {match.setNostri} - {match.setLoro}
@@ -147,7 +159,10 @@ function PartitaDetail() {
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{match.parziali.map(([noi, loro], i) => ( {match.parziali.map(([noi, loro], i) => (
<div key={i} className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"> <div
key={i}
className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"
>
<span className="font-display text-lg">{noi}</span> <span className="font-display text-lg">{noi}</span>
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span> <span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
<span className="font-display text-lg">{loro}</span> <span className="font-display text-lg">{loro}</span>
@@ -169,7 +184,8 @@ function PartitaDetail() {
) : ( ) : (
<Section titolo="In programma"> <Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card"> <p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il risultato. La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
risultato.
</p> </p>
</Section> </Section>
)} )}
@@ -224,10 +240,15 @@ function PartitaDetail() {
); );
})} })}
</div> </div>
{io && isAdmin(io.id) ? ( {io && isAdmin(io) ? (
<button <button
type="button" type="button"
onClick={() => scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout))} onClick={() =>
scaricaCsv(
`scout-${scout.data}-${scout.avversario}.csv`,
csvScoutMatch(giocatori, scout),
)
}
className="premi mt-4 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" className="premi mt-4 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 <Download className="h-4 w-4" /> Esporta CSV
+35 -11
View File
@@ -3,7 +3,8 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { giocatori, formatData, isAdmin } from "@/lib/crapp-data"; import { formatData, isAdmin } from "@/lib/crapp-data";
import { useRosaBase } from "@/lib/rosa-base";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze"; import { usePresenzeEvento } from "@/lib/presenze";
@@ -29,6 +30,7 @@ import {
totaliPerGiocatore, totaliPerGiocatore,
type Azione, type Azione,
type AzioneTipo, type AzioneTipo,
type ScoutMatch,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({ export const Route = createFileRoute("/scout")({
@@ -52,7 +54,17 @@ export const Route = createFileRoute("/scout")({
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"]; const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({ icona, titolo, testo, children }: { icona: React.ReactNode; titolo: string; testo: string; children?: React.ReactNode }) { function Blocco({
icona,
titolo,
testo,
children,
}: {
icona: React.ReactNode;
titolo: string;
testo: string;
children?: React.ReactNode;
}) {
return ( return (
<div className="px-5 py-10"> <div className="px-5 py-10">
<div className="rounded-3xl bg-card p-6 text-center shadow-card"> <div className="rounded-3xl bg-card p-6 text-center shadow-card">
@@ -88,7 +100,7 @@ function Scout() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [controllo, partita?.id, io?.id]); }, [controllo, partita?.id, io?.id]);
if (io && !isAdmin(io.id)) { if (io && !isAdmin(io)) {
return ( return (
<Blocco <Blocco
icona={<Lock className="h-6 w-6" />} icona={<Lock className="h-6 w-6" />}
@@ -99,7 +111,9 @@ function Scout() {
} }
if (!pronto || sessione.isLoading || statoSalvato.isLoading) { if (!pronto || sessione.isLoading || statoSalvato.isLoading) {
return <Blocco icona={<Radio className="h-6 w-6" />} titolo="Scout live" testo="Caricamento…" />; return (
<Blocco icona={<Radio className="h-6 w-6" />} titolo="Scout live" testo="Caricamento…" />
);
} }
if (!partita) { if (!partita) {
@@ -185,7 +199,10 @@ function ScoutBoard({
const base = const base =
iniziale ?? iniziale ??
statoIniziale( statoIniziale(
partita.titolo.replace(/CRAP Volley/gi, "").replace(/\s*vs\s*/i, "").trim() || "Avversario", partita.titolo
.replace(/CRAP Volley/gi, "")
.replace(/\s*vs\s*/i, "")
.trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"), partita.titolo.trim().toLowerCase().startsWith("crap"),
); );
const [avversario, setAvversario] = useState(base.avversario); const [avversario, setAvversario] = useState(base.avversario);
@@ -196,6 +213,7 @@ function ScoutBoard({
const salva = useSalvaStatoScout(); const salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout(); const cancella = useCancellaStatoScout();
const { risposte } = usePresenzeEvento(partita.id); const { risposte } = usePresenzeEvento(partita.id);
const giocatori = useRosaBase();
const finito = useRef(false); const finito = useRef(false);
/** In campo solo chi ha confermato la presenza (anche in ritardo). */ /** In campo solo chi ha confermato la presenza (anche in ritardo). */
@@ -204,7 +222,7 @@ function ScoutBoard({
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
); );
return presenti.length > 0 ? presenti : giocatori; return presenti.length > 0 ? presenti : giocatori;
}, [risposte]); }, [risposte, giocatori]);
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui. // Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
useEffect(() => { useEffect(() => {
@@ -271,7 +289,7 @@ function ScoutBoard({
return; return;
} }
const vinti = parziali.filter(([n, l]) => n > l).length; const vinti = parziali.filter(([n, l]) => n > l).length;
salvaScoutMatch({ const match: ScoutMatch = {
id: `s${Date.now()}`, id: `s${Date.now()}`,
data: new Date().toISOString().slice(0, 10), data: new Date().toISOString().slice(0, 10),
avversario: avversario.trim() || "Avversario", avversario: avversario.trim() || "Avversario",
@@ -281,7 +299,15 @@ function ScoutBoard({
parziali, parziali,
mvp: "", mvp: "",
azioni, azioni,
}); };
salvaScoutMatch(match);
// Best-effort: se il device non ha rete o Strapi è giù, il match resta comunque salvato
// in locale (fonte usata da useRosa/giocatoriConScout) esattamente come oggi.
fetch("/api/public/scout-finale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(match),
}).catch((error) => console.error("scout-finale", error));
toast.success("Partita salvata: ora la squadra può votare l'MVP"); toast.success("Partita salvata: ora la squadra può votare l'MVP");
finito.current = true; finito.current = true;
void cancella.mutateAsync(partita.id); void cancella.mutateAsync(partita.id);
@@ -437,9 +463,7 @@ function ScoutBoard({
i === 0 && "anim-riga", i === 0 && "anim-riga",
)} )}
> >
<span className="truncate"> <span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
{g ? `#${g.numero} ${g.nome}` : "Avversario"}
</span>
<span <span
className={cn( className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase", "shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase",
+31 -10
View File
@@ -4,7 +4,8 @@ import { Cake, ChevronDown, Crown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { formatData, storicoMatch } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { useStoricoMatch } from "@/lib/storico-match";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useRosa, useObiettivi } from "@/lib/rosa"; import { useRosa, useObiettivi } from "@/lib/rosa";
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese"; import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
@@ -43,7 +44,8 @@ export const Route = createFileRoute("/squadra")({
{ title: "Squadra CRAP Volley — CrAPP" }, { title: "Squadra CRAP Volley — CrAPP" },
{ {
name: "description", name: "description",
content: "Rosa completa del CRAP Volley: giocatori, dati anagrafici, statistiche stagionali e badge sbloccati.", content:
"Rosa completa del CRAP Volley: giocatori, dati anagrafici, statistiche stagionali e badge sbloccati.",
}, },
{ property: "og:title", content: "Squadra CRAP Volley — CrAPP" }, { property: "og:title", content: "Squadra CRAP Volley — CrAPP" },
{ {
@@ -55,7 +57,6 @@ export const Route = createFileRoute("/squadra")({
component: Squadra, component: Squadra,
}); });
const criteri = [ const criteri = [
{ id: "presenze", label: "Presenze" }, { id: "presenze", label: "Presenze" },
{ id: "mediaVoto", label: "Media voto" }, { id: "mediaVoto", label: "Media voto" },
@@ -75,6 +76,7 @@ function valore(
function Squadra() { function Squadra() {
const rosa = useRosa(); const rosa = useRosa();
const storicoMatch = useStoricoMatch();
const mese = usePresenzeUltimoMeseTutti(); const mese = usePresenzeUltimoMeseTutti();
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const votiMvp = useVotiMvp(); const votiMvp = useVotiMvp();
@@ -85,7 +87,9 @@ function Squadra() {
const obiettivi = useObiettivi(); const obiettivi = useObiettivi();
const team = totaliSquadra(scoutMatches); const team = totaliSquadra(scoutMatches);
const mediaPresenze = rosa.length const mediaPresenze = rosa.length
? Math.round((rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100) ? Math.round(
(rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100,
)
: 0; : 0;
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio)); const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1; const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
@@ -116,7 +120,10 @@ function Squadra() {
<div className="space-y-2"> <div className="space-y-2">
{rosa.map((g) => { {rosa.map((g) => {
const stati = badgeGiocatore(g); const stati = badgeGiocatore(g);
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)]; const sbloccati = [
...stati.filter((b) => b.grado !== null),
...badgeSegretiSbloccati(g),
];
const isOpen = aperto === g.id; const isOpen = aperto === g.id;
return ( return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card"> <article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
@@ -126,7 +133,11 @@ function Squadra() {
className="flex w-full items-center gap-3 p-3 text-left active:scale-[0.99]" className="flex w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<Avatar id={g.id} fallback={g.numero ? String(g.numero) : g.iniziali} className="h-11 w-11 text-lg" /> <Avatar
id={g.id}
fallback={g.numero ? String(g.numero) : g.iniziali}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span> <span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="mt-1 flex items-center gap-2"> <span className="mt-1 flex items-center gap-2">
@@ -160,7 +171,8 @@ function Squadra() {
<div className="border-t border-border px-4 pb-4 pt-3"> <div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)} {g.nascita.slice(0, 4)} <Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p> </p>
</div> </div>
@@ -201,9 +213,16 @@ function Squadra() {
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p> <p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{b.valore} {b.def.unita} {b.valore} {b.def.unita}
{b.prossimaSoglia ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` : ""} {b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p> </p>
<p className={cn("mt-1.5 text-[10px] font-bold uppercase", meta.text)}> <p
className={cn(
"mt-1.5 text-[10px] font-bold uppercase",
meta.text,
)}
>
{meta.label} {meta.label}
</p> </p>
</div> </div>
@@ -411,7 +430,9 @@ function Squadra() {
const meta = gradoMeta[grado]; const meta = gradoMeta[grado];
const quanti = rosa.filter((g) => { const quanti = rosa.filter((g) => {
const raggiunto = gradoRaggiunto(b, b.valore(g)); const raggiunto = gradoRaggiunto(b, b.valore(g));
return raggiunto ? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado) : false; return raggiunto
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
: false;
}).length; }).length;
return ( return (
<div <div