From 73902cc0a445a0cbdc57d65c858147abba0d669f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 6 Sep 2026 00:08:58 +0200 Subject: [PATCH] Apre il sondaggio pre-partita alle 8:00 con avviso push manuale. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il sondaggio cacche era votabile in qualsiasi momento, anche settimane prima della partita. Ora `sondaggioAperto()` lo sblocca alle 8:00 del giorno della partita e prima la card mostra solo l'avviso di apertura. Aggiunge la route `POST /api/public/apri-sondaggio` e, per gli amministratori, il pulsante «Avvisa tutti del sondaggio» nella card: manda la push a tutti i dispositivi iscritti, con lo stesso meccanismo del sollecito presenze. Nessun invio automatico. Co-Authored-By: Claude Opus 5 --- docs/CHANGELOG.md | 8 +++ docs/modules/notifiche.md | 3 ++ docs/modules/scout-live.md | 9 +++- src/components/crapp/SondaggioCacche.tsx | 62 +++++++++++++++++++++++- src/lib/cacche.ts | 11 +++++ src/lib/palloni-core.ts | 3 +- src/routeTree.gen.ts | 21 ++++++++ src/routes/api/public/apri-sondaggio.ts | 52 ++++++++++++++++++++ src/routes/partita.$id.tsx | 2 +- test/unit/cacche.test.ts | 9 ++++ 10 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/routes/api/public/apri-sondaggio.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b1fc9d2..296226e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,14 @@ qui: sta in [ROADMAP.md](ROADMAP.md). ## Versione attuale — agosto 2026 +### Il sondaggio pre-partita apre alle 8:00 del giorno della partita + +- Prima era sempre votabile, anche settimane prima: ora la card resta chiusa con l'avviso di + apertura e si sblocca alle 8:00 del giorno stesso. +- Quando è aperto, gli amministratori hanno nella card il pulsante «Avvisa tutti del + sondaggio» (`POST /api/public/apri-sondaggio`), che manda la push a tutti i dispositivi + iscritti — stesso meccanismo del sollecito presenze. Nessun cron: l'invio è manuale. + ### Le note dell'evento si leggono aprendolo - Il campo Note del form eventi si poteva scrivere ma non lo vedeva nessuno: ora compare diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 46981db..e89edf7 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -46,6 +46,9 @@ smart in app, che usano lo stesso service worker. - **`push-config`** — espone la sola chiave pubblica VAPID. - **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo. +- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: mette in coda su + `promemoria_push` l'avviso di apertura del sondaggio pre-partita per **tutti** i dispositivi + iscritti e manda la push (vedi [Scout Live](scout-live.md)). - **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push` diff --git a/docs/modules/scout-live.md b/docs/modules/scout-live.md index b1049c6..a77eea0 100644 --- a/docs/modules/scout-live.md +++ b/docs/modules/scout-live.md @@ -82,8 +82,13 @@ sezione "Report tecnico". ## Sondaggio cacche `SondaggioCacche.tsx` chiede "quante cacche hai fatto prima di questa partita" (0-5+), sempre -modificabile, senza gating temporale reale (visibile sia prima sia dopo la partita nonostante -il nome). `statisticheCacche()` (`cacche.ts`) calcola media, record e `giornateTop` +modificabile. `sondaggioAperto()` (`cacche.ts`) lo apre alle **8:00 del giorno della partita** +(ora locale del dispositivo) e da lì lo lascia aperto per sempre; prima la card mostra solo +l'avviso di apertura. Quando è aperto, gli **amministratori** vedono nella card il pulsante +«Avvisa tutti del sondaggio»: chiama `POST /api/public/apri-sondaggio` e manda la push a tutti +i dispositivi iscritti, come il sollecito presenze (vedi [Notifiche](notifiche.md)). Nessun +invio automatico: parte solo quando un admin lo preme. +`statisticheCacche()` (`cacche.ts`) calcola media, record e `giornateTop` (giornate con ≥3), soglia usata per un [badge](badge.md) segreto — coerente con DD-007 (badge calcolati a runtime). diff --git a/src/components/crapp/SondaggioCacche.tsx b/src/components/crapp/SondaggioCacche.tsx index 8cd4ca5..1de6982 100644 --- a/src/components/crapp/SondaggioCacche.tsx +++ b/src/components/crapp/SondaggioCacche.tsx @@ -1,18 +1,29 @@ +import { useState } from "react"; +import { BellRing, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Card } from "@/components/crapp/ui-bits"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { useGiocatoreCorrente } from "@/lib/user-store"; -import { mediaPartita, useCacche, useSalvaCacche } from "@/lib/cacche"; +import { useIsAdmin } from "@/lib/ruoli"; +import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche"; const opzioni = [0, 1, 2, 3, 4, 5]; /** Sondaggio goliardico pre-partita: quante cacche prima del fischio d'inizio. */ -export function SondaggioCacche({ eventoId }: { eventoId: string }) { +export function SondaggioCacche({ + eventoId, + dataEvento, +}: { + eventoId: string; + dataEvento: string; +}) { const io = useGiocatoreCorrente(); const { righe } = useCacche(); const salva = useSalvaCacche(); const { righe: squadra } = useGiocatoriSquadra(); + const admin = useIsAdmin(); + const [avviso, setAvviso] = useState(false); const dellaPartita = righe.filter((r) => r.evento_id === eventoId); const mia = dellaPartita.find((r) => r.giocatore_id === io?.id); @@ -35,6 +46,41 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) { } } + async function avvisaTutti() { + setAvviso(true); + try { + const res = await fetch("/api/public/apri-sondaggio", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ eventoId }), + }); + if (!res.ok) throw new Error(); + const dati = (await res.json()) as { inviate: number; destinatari: number }; + toast.success( + dati.inviate > 0 + ? `Notifica inviata a ${dati.inviate} dispositivi` + : "Nessun dispositivo con le notifiche attive", + ); + } catch { + toast.error("Non sono riuscito a inviare la notifica"); + } finally { + setAvviso(false); + } + } + + if (!sondaggioAperto(dataEvento)) { + return ( + +

+ 💩 Sondaggio pre-partita +

+

+ Apre alle 8:00 del giorno della partita: riceverai una notifica. +

+
+ ); + } + return (
@@ -81,6 +127,18 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) { ))}
+ + {admin ? ( + + ) : null}
); } diff --git a/src/lib/cacche.ts b/src/lib/cacche.ts index bbdafd9..09ef080 100644 --- a/src/lib/cacche.ts +++ b/src/lib/cacche.ts @@ -1,5 +1,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; +import { oggiISO } from "./palloni-core"; + +/** Ora di apertura del sondaggio, il giorno stesso della partita. */ +export const ORA_APERTURA_SONDAGGIO = 8; + +/** Il sondaggio apre alle 8:00 del giorno della partita e da lì resta aperto. */ +export function sondaggioAperto(dataEvento: string, adesso = new Date()): boolean { + const oggi = oggiISO(adesso); + if (dataEvento !== oggi) return dataEvento < oggi; + return adesso.getHours() >= ORA_APERTURA_SONDAGGIO; +} /** Sondaggio goliardico pre-partita: quante cacche prima del match di campionato. */ export type RigaCacche = { diff --git a/src/lib/palloni-core.ts b/src/lib/palloni-core.ts index 92c5ff8..45bbc5c 100644 --- a/src/lib/palloni-core.ts +++ b/src/lib/palloni-core.ts @@ -84,8 +84,7 @@ export function eventoSuccessivo(eventi: Evento[], eventoId: string): Evento | u return i >= 0 ? lista[i + 1] : undefined; } -export function oggiISO(): string { - const d = new Date(); +export function oggiISO(d = new Date()): string { const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${d.getFullYear()}-${mm}-${dd}`; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3378673..dad9b15 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ScoutRouteImport } from './routes/scout' import { Route as SquadraRouteImport } from './routes/squadra' import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id' import { Route as PartitaIdRouteImport } from './routes/partita.$id' +import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio' import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' @@ -82,6 +83,11 @@ const PartitaIdRoute = PartitaIdRouteImport.update({ path: '/partita/$id', getParentRoute: () => rootRouteImport, } as any) +const ApiPublicApriSondaggioRoute = ApiPublicApriSondaggioRouteImport.update({ + id: '/api/public/apri-sondaggio', + path: '/api/public/apri-sondaggio', + getParentRoute: () => rootRouteImport, +} as any) const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({ id: '/api/public/csi', path: '/api/public/csi', @@ -127,6 +133,7 @@ export interface FileRoutesByFullPath { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -146,6 +153,7 @@ export interface FileRoutesByTo { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -166,6 +174,7 @@ export interface FileRoutesById { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -187,6 +196,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/apri-sondaggio' | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -206,6 +216,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/apri-sondaggio' | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -225,6 +236,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/apri-sondaggio' | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -245,6 +257,7 @@ export interface RootRouteChildren { SquadraRoute: typeof SquadraRoute AllenamentoIdRoute: typeof AllenamentoIdRoute PartitaIdRoute: typeof PartitaIdRoute + ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute @@ -332,6 +345,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PartitaIdRouteImport parentRoute: typeof rootRouteImport } + '/api/public/apri-sondaggio': { + id: '/api/public/apri-sondaggio' + path: '/api/public/apri-sondaggio' + fullPath: '/api/public/apri-sondaggio' + preLoaderRoute: typeof ApiPublicApriSondaggioRouteImport + parentRoute: typeof rootRouteImport + } '/api/public/csi': { id: '/api/public/csi' path: '/api/public/csi' @@ -389,6 +409,7 @@ const rootRouteChildren: RootRouteChildren = { SquadraRoute: SquadraRoute, AllenamentoIdRoute: AllenamentoIdRoute, PartitaIdRoute: PartitaIdRoute, + ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute, ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, diff --git a/src/routes/api/public/apri-sondaggio.ts b/src/routes/api/public/apri-sondaggio.ts new file mode 100644 index 0000000..e614fb1 --- /dev/null +++ b/src/routes/api/public/apri-sondaggio.ts @@ -0,0 +1,52 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { leggiEventi } from "@/lib/eventi.server"; +import { inviaPush } from "@/lib/webpush.server"; + +const schema = z.object({ eventoId: z.string().min(1).max(50) }); + +/** Avviso "sondaggio pre-partita aperto": lo fa partire un admin dalla pagina partita. */ +export const Route = createFileRoute("/api/public/apri-sondaggio")({ + server: { + handlers: { + POST: async ({ request }) => { + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) return new Response("Dati non validi", { status: 400 }); + + const eventi = await leggiEventi(); + const partita = eventi.find((e) => e.id === parsed.data.eventoId); + if (!partita) return new Response("Evento non trovato", { status: 404 }); + + const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); + const { data: iscrizioni } = await supabaseAdmin + .from("push_subscriptions") + .select("endpoint"); + + const titolo = "💩 Sondaggio pre-partita aperto"; + const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`; + + let inviate = 0; + for (const iscrizione of iscrizioni ?? []) { + try { + await supabaseAdmin + .from("promemoria_push") + .insert({ endpoint: iscrizione.endpoint, titolo, testo }); + const stato = await inviaPush(iscrizione.endpoint); + if (stato === 404 || stato === 410) { + await supabaseAdmin + .from("push_subscriptions") + .delete() + .eq("endpoint", iscrizione.endpoint); + } else if (stato >= 200 && stato < 300) { + inviate += 1; + } + } catch (error) { + console.error("apri-sondaggio", error); + } + } + + return Response.json({ inviate, destinatari: (iscrizioni ?? []).length }); + }, + }, + }, +}); diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 22d3401..47468b8 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -205,7 +205,7 @@ function PartitaDetail() { )}
- +
{match ? ( diff --git a/test/unit/cacche.test.ts b/test/unit/cacche.test.ts index 8f8abdd..152d400 100644 --- a/test/unit/cacche.test.ts +++ b/test/unit/cacche.test.ts @@ -4,6 +4,7 @@ import { mediaPartita, mediaStagione, recordStagione, + sondaggioAperto, statisticheCacche, type RigaCacche, } from "@/lib/cacche"; @@ -52,4 +53,12 @@ assert.deepEqual( "a parità vince chi l'ha fatto per primo", ); +// --- sondaggioAperto --------------------------------------------------------- +const alle = (h: number, m = 0) => new Date(2026, 8, 5, h, m); // 5 settembre 2026 +assert.equal(sondaggioAperto("2026-09-05", alle(7, 59)), false, "prima delle 8 è chiuso"); +assert.equal(sondaggioAperto("2026-09-05", alle(8)), true, "alle 8 in punto apre"); +assert.equal(sondaggioAperto("2026-09-05", alle(23)), true); +assert.equal(sondaggioAperto("2026-09-06", alle(23)), false, "partita di domani: chiuso"); +assert.equal(sondaggioAperto("2026-09-04", alle(0, 1)), true, "partita passata: resta aperto"); + console.log("cacche: ok");