Apre il sondaggio pre-partita alle 8:00 con avviso push manuale.

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 00:08:58 +02:00
co-authored by Claude Opus 5
parent a7d0f5af5b
commit 73902cc0a4
10 changed files with 173 additions and 7 deletions
+60 -2
View File
@@ -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 (
<Card>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<p className="mt-1 text-xs text-muted-foreground">
Apre alle 8:00 del giorno della partita: riceverai una notifica.
</p>
</Card>
);
}
return (
<Card>
<div className="flex items-center justify-between gap-2">
@@ -81,6 +127,18 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
</span>
))}
</div>
{admin ? (
<button
type="button"
onClick={avvisaTutti}
disabled={avviso}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
>
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
Avvisa tutti del sondaggio
</button>
) : null}
</Card>
);
}
+11
View File
@@ -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 = {
+1 -2
View File
@@ -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}`;
+21
View File
@@ -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,
+52
View File
@@ -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 });
},
},
},
});
+1 -1
View File
@@ -205,7 +205,7 @@ function PartitaDetail() {
)}
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} />
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} />
</Section>
{match ? (