2 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 a69bcb3f0d Porta la versione a 1.4.2: link Maps, testo «Apri su Maps»
Il pulsante delle schede di partita, allenamento ed evento dice «Apri su Maps».
CHANGELOG con la sezione 1.4.2 (m34 applicata in produzione il 26/10/2026),
PROJECT_STATE e package.json/package-lock alla 1.4.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:20:42 +01:00
davideandClaude Sonnet 5.5 19ddf715e6 Link Google Maps del luogo degli eventi (m34)
Colonna facoltativa eventi_app.link_maps con CHECK che accetta solo URL https di
Google Maps. Campo nel form di /eventi (admin e allenatori), validazione con
linkMapsValido() e test. Le schede di partita, allenamento ed evento mostrano
«Apri su Google Maps» solo se il link c'è; card e notifiche non cambiano.
Documentazione: calendario.md, DATABASE.md, CHANGELOG.

La migration m34 non è ancora applicata in produzione: va fatto db push prima di
rilasciare questo codice, che legge la nuova colonna.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:12:07 +01:00
15 changed files with 158 additions and 17 deletions

No files matched your search

+6 -4
View File
@@ -9,16 +9,18 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.4.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — gli eventi con convocati si vedono - **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — ogni evento può avere un link
solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0: avviso «Presenza modificata» ad admin e Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0 compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 44 file in `supabase/migrations/`, fino a `m33_presenza_modificata_allenatori`. Tutte applicate in produzione (verificato con `npx supabase migration list` il - **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
09/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie 26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato. `foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
+12
View File
@@ -13,6 +13,18 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
26/10/2026.
### Aggiunto
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
## [1.4.1] - 2026-10-19 ## [1.4.1] - 2026-10-19
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati. Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
+7 -7
View File
@@ -74,13 +74,13 @@ Colonne di `giocatori_squadra` che meritano una nota:
## Eventi e presenze ## Eventi e presenze
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. | | `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. | | `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. | | `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). | | `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | | `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout ## Scout
+7
View File
@@ -53,6 +53,13 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`. `eventi_app`.
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
luogo, e né la card né le notifiche lo mostrano.
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`) Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.4.1", "version": "1.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.4.1", "version": "1.4.2",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.4.1", "version": "1.4.2",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+3
View File
@@ -145,6 +145,7 @@ export type Database = {
data: string; data: string;
id: string; id: string;
luogo: string; luogo: string;
link_maps: string | null;
note: string; note: string;
ora: string; ora: string;
pagelle_chiuse: boolean; pagelle_chiuse: boolean;
@@ -160,6 +161,7 @@ export type Database = {
data: string; data: string;
id: string; id: string;
luogo?: string; luogo?: string;
link_maps?: string | null;
note?: string; note?: string;
ora?: string; ora?: string;
pagelle_chiuse?: boolean; pagelle_chiuse?: boolean;
@@ -175,6 +177,7 @@ export type Database = {
data?: string; data?: string;
id?: string; id?: string;
luogo?: string; luogo?: string;
link_maps?: string | null;
note?: string; note?: string;
ora?: string; ora?: string;
pagelle_chiuse?: boolean; pagelle_chiuse?: boolean;
+1 -1
View File
@@ -1,7 +1,7 @@
import { daRiga, type Evento, type RigaEvento } from "./eventi"; import { daRiga, type Evento, type RigaEvento } from "./eventi";
const COLONNE = const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse"; "id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
/** Lettura eventi lato server (route API): stessa conversione del client. */ /** Lettura eventi lato server (route API): stessa conversione del client. */
export async function leggiEventi(): Promise<Evento[]> { export async function leggiEventi(): Promise<Evento[]> {
+18 -1
View File
@@ -9,6 +9,8 @@ export type Evento = {
tipo: EventoTipo; tipo: EventoTipo;
titolo: string; titolo: string;
luogo: string; luogo: string;
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
linkMaps?: string | undefined;
data: string; data: string;
ora: string; ora: string;
note: string; note: string;
@@ -30,6 +32,7 @@ export type RigaEvento = {
tipo: string; tipo: string;
titolo: string; titolo: string;
luogo: string; luogo: string;
link_maps?: string | null;
data: string; data: string;
ora: string; ora: string;
note: string | null; note: string | null;
@@ -47,6 +50,7 @@ export function daRiga(r: RigaEvento): Evento {
tipo: (r.tipo as EventoTipo) ?? "evento", tipo: (r.tipo as EventoTipo) ?? "evento",
titolo: r.titolo, titolo: r.titolo,
luogo: r.luogo ?? "", luogo: r.luogo ?? "",
linkMaps: r.link_maps ?? "",
data: r.data, data: r.data,
ora: r.ora ?? "", ora: r.ora ?? "",
note: r.note ?? "", note: r.note ?? "",
@@ -59,7 +63,7 @@ export function daRiga(r: RigaEvento): Evento {
} }
const COLONNE = const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il"; "id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */ /** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento"; export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
@@ -76,6 +80,17 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
return { tipo: c, campionato: false }; return { tipo: c, campionato: false };
} }
/**
* Il link è accettato solo se punta a Google Maps (stesso criterio del CHECK su
* `eventi_app.link_maps`): è un URL che gli utenti toccano, quindi niente `javascript:` né siti terzi.
*/
const LINK_MAPS =
/^https:\/\/(maps\.app\.goo\.gl\/|goo\.gl\/maps\/|maps\.google\.[a-z.]+\/|(www\.)?google\.[a-z.]+\/maps)\S*$/;
export function linkMapsValido(url: string): boolean {
return url.length <= 500 && LINK_MAPS.test(url);
}
export const EVENTI_KEY = ["eventi"] as const; export const EVENTI_KEY = ["eventi"] as const;
async function fetchEventi(): Promise<Evento[]> { async function fetchEventi(): Promise<Evento[]> {
@@ -124,6 +139,7 @@ export function eventoVuoto(): Evento {
tipo: "allenamento", tipo: "allenamento",
titolo: "", titolo: "",
luogo: "", luogo: "",
linkMaps: "",
data: new Date().toISOString().slice(0, 10), data: new Date().toISOString().slice(0, 10),
ora: "20:30", ora: "20:30",
note: "", note: "",
@@ -144,6 +160,7 @@ export function useSalvaEvento() {
tipo: evento.tipo, tipo: evento.tipo,
titolo: evento.titolo, titolo: evento.titolo,
luogo: evento.luogo, luogo: evento.luogo,
link_maps: evento.linkMaps?.trim() || null,
data: evento.data, data: evento.data,
ora: evento.ora, ora: evento.ora,
note: evento.note, note: evento.note,
+10
View File
@@ -75,6 +75,16 @@ function AllenamentoDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div> </div>
{evento.note ? ( {evento.note ? (
+24 -1
View File
@@ -22,6 +22,7 @@ import {
categoriaEvento, categoriaEvento,
daCategoria, daCategoria,
eventoVuoto, eventoVuoto,
linkMapsValido,
useEliminaEvento, useEliminaEvento,
useSalvaEvento, useSalvaEvento,
type CategoriaEvento, type CategoriaEvento,
@@ -111,6 +112,12 @@ function GestioneEventi() {
toast.error("Serve un titolo per l'evento"); toast.error("Serve un titolo per l'evento");
return; return;
} }
if ((bozza.linkMaps ?? "").trim() && !linkMapsValido(bozza.linkMaps!.trim())) {
toast.error(
"Il link deve essere di Google Maps (https://maps.app.goo.gl/… o google.com/maps…)",
);
return;
}
if (modificaEsistente) { if (modificaEsistente) {
setConfermaModifica(true); setConfermaModifica(true);
return; return;
@@ -121,7 +128,11 @@ function GestioneEventi() {
async function conferma() { async function conferma() {
if (!bozza) return; if (!bozza) return;
try { try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() }); await salva.mutateAsync({
...bozza,
titolo: bozza.titolo.trim(),
linkMaps: (bozza.linkMaps ?? "").trim(),
});
toast.success("Evento salvato"); toast.success("Evento salvato");
setBozza(null); setBozza(null);
} catch { } catch {
@@ -373,6 +384,18 @@ function GestioneEventi() {
/> />
</Campo> </Campo>
<Campo label="Link Google Maps (facoltativo)">
<input
type="url"
inputMode="url"
value={bozza.linkMaps ?? ""}
maxLength={500}
placeholder="Incolla il link da Condividi → Copia link"
onChange={(e) => aggiorna({ linkMaps: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Note"> <Campo label="Note">
<textarea <textarea
value={bozza.note} value={bozza.note}
+10
View File
@@ -72,6 +72,16 @@ function EventoDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div> </div>
{evento.note ? ( {evento.note ? (
+10
View File
@@ -133,6 +133,16 @@ function PartitaDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div> </div>
{evento.note ? ( {evento.note ? (
@@ -0,0 +1,16 @@
-- M34 — Link Google Maps del luogo di un evento
--
-- Colonna facoltativa: NULL = nessun link. Il CHECK accetta solo URL https di Google Maps (stesso
-- criterio di `linkMapsValido()` in src/lib/eventi.ts), perché il link è mostrato a tutta la squadra
-- come pulsante. Chi lo scrive è già limitato ad admin e allenatori dalle policy di `eventi_app` (M11).
ALTER TABLE public.eventi_app
ADD COLUMN link_maps text,
ADD CONSTRAINT eventi_app_link_maps_google
CHECK (
link_maps IS NULL
OR (
length(link_maps) <= 500
AND link_maps ~ '^https://(maps\.app\.goo\.gl/|goo\.gl/maps/|maps\.google\.[a-z.]+/|(www\.)?google\.[a-z.]+/maps)\S*$'
)
);
+31
View File
@@ -11,6 +11,7 @@ import {
eventoVisibile, eventoVisibile,
rosaConvocata, rosaConvocata,
eventoVuoto, eventoVuoto,
linkMapsValido,
type RigaEvento, type RigaEvento,
} from "@/lib/eventi"; } from "@/lib/eventi";
@@ -35,6 +36,7 @@ assert.deepEqual(daRiga(riga), {
tipo: "partita", tipo: "partita",
titolo: "CRAP - Avversari", titolo: "CRAP - Avversari",
luogo: "PalaCRAP", luogo: "PalaCRAP",
linkMaps: "",
data: "2026-09-01", data: "2026-09-01",
ora: "21:00", ora: "21:00",
note: "portare la seconda maglia", note: "portare la seconda maglia",
@@ -172,3 +174,32 @@ for (const { nome, convocati, attesi } of casi) {
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`); assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
} }
assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota"); assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota");
// Link Google Maps: solo URL https di Google Maps, e vuoto se la riga non ne ha uno.
assert.equal(daRiga(riga).linkMaps, "");
assert.equal(daRiga({ ...riga, link_maps: null }).linkMaps, "");
assert.equal(
daRiga({ ...riga, link_maps: "https://maps.app.goo.gl/abc123" }).linkMaps,
"https://maps.app.goo.gl/abc123",
);
assert.equal(eventoVuoto().linkMaps, "");
for (const ok of [
"https://maps.app.goo.gl/abc123",
"https://goo.gl/maps/xyz",
"https://www.google.com/maps/place/PalaCRAP",
"https://google.it/maps?q=Cesena",
"https://maps.google.com/?q=Cesena",
]) {
assert.equal(linkMapsValido(ok), true, ok);
}
for (const ko of [
"",
"javascript:alert(1)",
"http://maps.app.goo.gl/abc123",
"https://example.com/maps",
"https://maps.app.goo.gl.evil.com/abc",
"https://www.google.com/search?q=maps",
"https://maps.app.goo.gl/a b",
]) {
assert.equal(linkMapsValido(ko), false, ko);
}