Allinea l'app ai nuovi testi e ai destinatari dei pulsanti (DD-040)
I pulsanti «Avvisa chi è di turno» e «Sollecita» usano gli stessi testi dei job del database. Il sollecito manuale segue i convocati e dice la risposta attuale e chi lo ha chiesto. L'ordine degli eventi dei palloni diventa data, ora, id. Le righe del corpo di una notifica vanno a capo nel centro notifiche. Test unitari dei testi, dei destinatari e delle date; il test delle route verifica testi, convocati e assenza di email per i palloni. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
563142a30a
commit
9b4e3e4b45
12 files changed
+363
-48
No files matched your search
@@ -110,7 +110,9 @@ function RigaNotifica({
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||
>
|
||||
<p className="font-semibold">{notifica.titolo}</p>
|
||||
{notifica.corpo ? <p className="mt-0.5 text-muted-foreground">{notifica.corpo}</p> : null}
|
||||
{notifica.corpo ? (
|
||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
</motion.div>
|
||||
<button
|
||||
|
||||
@@ -133,6 +133,12 @@ export type RigaClassifica = {
|
||||
punti: number;
|
||||
};
|
||||
|
||||
/** Data numerica `GG/MM/AAAA`, come scritta dai testi delle notifiche (stessa del database). */
|
||||
export function formatDataNumerica(iso: string): string {
|
||||
const [anno, mese, giorno] = iso.split("-");
|
||||
return `${giorno}/${mese}/${anno}`;
|
||||
}
|
||||
|
||||
export function formatData(iso: string) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
|
||||
|
||||
@@ -16,7 +16,14 @@ export type TipoNotifica =
|
||||
| "evento_promemoria_24h"
|
||||
| "evento_promemoria_3h"
|
||||
| "turno_palloni"
|
||||
| "turno_palloni_12h"
|
||||
| "turno_palloni_6h"
|
||||
| "turno_palloni_3h"
|
||||
| "turno_palloni_revocato"
|
||||
| "sollecita_presenze"
|
||||
| "sollecita_presenze_24h"
|
||||
| "sollecita_presenze_12h"
|
||||
| "sollecita_presenze_6h"
|
||||
| "sondaggio_cacche";
|
||||
|
||||
export type NotificaUtente = {
|
||||
|
||||
+39
-23
@@ -1,4 +1,4 @@
|
||||
import { formatData } from "./crapp-data";
|
||||
import { formatDataNumerica } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { dataOggi } from "./scout-live";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
@@ -8,12 +8,19 @@ export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: st
|
||||
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
|
||||
export type CandidatoTurno = { id: string; nome: string };
|
||||
|
||||
/** Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data. */
|
||||
/**
|
||||
* Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data e, a parità,
|
||||
* di ora. Lo stesso ordine lo usa il database per i job automatici (M26, DD-040).
|
||||
*/
|
||||
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
return eventi
|
||||
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
|
||||
.slice()
|
||||
.sort((a, b) => a.data.localeCompare(b.data));
|
||||
.sort((a, b) => {
|
||||
if (a.data !== b.data) return a.data < b.data ? -1 : 1;
|
||||
if (a.ora !== b.ora) return a.ora < b.ora ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,11 +146,12 @@ export function destinatariPromemoriaPalloni(
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025, DD-040).
|
||||
*
|
||||
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
|
||||
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
|
||||
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
|
||||
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
|
||||
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
|
||||
* «riconsegna». Se per l'evento non c'è un incaricato non parte nulla. Il testo viaggia cifrato
|
||||
* dentro la push, quindi il service worker lo mostra senza rete.
|
||||
*/
|
||||
export function avvisiPalloniEvento(
|
||||
turni: Record<string, string>,
|
||||
@@ -153,29 +161,37 @@ export function avvisiPalloniEvento(
|
||||
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
|
||||
if (!evento) return [];
|
||||
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
|
||||
|
||||
const incaricato = turni[evento.id];
|
||||
if (incaricato) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Tocca a te prendere i palloni",
|
||||
testo: dopo
|
||||
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
|
||||
: `${quando}: a fine evento porti a casa i palloni.`,
|
||||
});
|
||||
}
|
||||
if (!incaricato) return [];
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const intestazione = [
|
||||
`Evento: ${evento.titolo}`,
|
||||
`Data: ${formatDataNumerica(evento.data)}, ore ${evento.ora}`,
|
||||
];
|
||||
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Turno palloni: incarico assegnato",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: custodia dei palloni al termine dell'evento",
|
||||
...(dopo ? [`Riconsegna: ${formatDataNumerica(dopo.data)}`] : []),
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di riportarli.
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
const precedente = prima ? turni[prima.id] : undefined;
|
||||
if (precedente && precedente !== incaricato) {
|
||||
avvisi.push({
|
||||
giocatoreId: precedente,
|
||||
titolo: "Porta i palloni",
|
||||
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
|
||||
titolo: "Turno palloni: riconsegna",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Stato } from "./crapp-data";
|
||||
import { formatDataNumerica, type Stato } from "./crapp-data";
|
||||
import type { Evento, EventoTipo } from "./eventi";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
@@ -82,18 +82,22 @@ export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
|
||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
|
||||
* Chi va sollecitato per un evento (DD-040): i destinatari dell'evento — i convocati, o tutta la
|
||||
* rosa se `convocati` è vuoto — che non hanno ancora risposto o hanno risposto «forse». Mai gli
|
||||
* allenatori (DD-034). Stessa regola del job automatico del database (`giocatori_destinatari_evento`
|
||||
* più `genera_solleciti_presenze`, migration M26): funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni, chiamata dalla route `/api/public/sollecita-presenze` con i dati che ha già letto.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
convocati: string[] = [],
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
|
||||
return squadra
|
||||
.filter(inRosa)
|
||||
.filter((g) => convocati.length === 0 || convocati.includes(g.id))
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
@@ -101,6 +105,29 @@ export function destinatariSollecito(
|
||||
.map((g) => g.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Titolo e corpo del sollecito, gli stessi del job automatico (migration M26). `da` è il nome di
|
||||
* chi preme il pulsante: compare solo nel sollecito manuale, come riga «Richiesta di».
|
||||
*/
|
||||
export function testoSollecito(
|
||||
evento: Pick<Evento, "titolo" | "data" | "ora" | "luogo">,
|
||||
stato: string | undefined,
|
||||
da?: string,
|
||||
): { titolo: string; testo: string } {
|
||||
const luogo = evento.luogo.trim();
|
||||
return {
|
||||
titolo: `Conferma di partecipazione richiesta: ${evento.titolo}`,
|
||||
testo: [
|
||||
`Data: ${formatDataNumerica(evento.data)}`,
|
||||
`Ora: ${evento.ora}`,
|
||||
...(luogo ? [`Luogo: ${luogo}`] : []),
|
||||
...(da ? [`Richiesta di: ${da}`] : []),
|
||||
`Risposta attuale: ${stato === "forse" ? "forse" : "nessuna"}`,
|
||||
"Azione: indicare presente, assente o in ritardo",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serie di presenze consecutive su eventi già passati, in ordine di data:
|
||||
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
|
||||
|
||||
@@ -2,10 +2,9 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediGestoreEventi } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { destinatariSollecito } from "@/lib/presenze";
|
||||
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -35,19 +34,24 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
.eq("evento_id", evento.id);
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? []);
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? [], evento.convocati);
|
||||
|
||||
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth")
|
||||
.select("endpoint, giocatore_id, p256dh, auth")
|
||||
.in("giocatore_id", destinatari);
|
||||
|
||||
const titolo = "Manca la tua risposta";
|
||||
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
|
||||
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
|
||||
} una conferma: presente, assente o in ritardo?`;
|
||||
// Il testo dice se il giocatore non ha risposto o ha risposto «forse» (DD-040): è
|
||||
// quindi per destinatario, e la push porta il testo del proprio destinatario.
|
||||
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
|
||||
const testi = new Map(
|
||||
destinatari.map((giocatoreId) => [
|
||||
giocatoreId,
|
||||
testoSollecito(evento, stati.get(giocatoreId), parsed.data.da),
|
||||
]),
|
||||
);
|
||||
|
||||
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
|
||||
// notifica a non letta, non fallire per il vincolo UNIQUE. `types.ts` non include
|
||||
@@ -57,8 +61,8 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
destinatari.map((giocatoreId) => ({
|
||||
giocatore_id: giocatoreId,
|
||||
tipo: "sollecita_presenze",
|
||||
titolo,
|
||||
corpo: testo,
|
||||
titolo: testi.get(giocatoreId)?.titolo,
|
||||
corpo: testi.get(giocatoreId)?.testo,
|
||||
evento_id: evento.id,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
@@ -68,8 +72,10 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
const avviso = testi.get(iscrizione.giocatore_id);
|
||||
if (!avviso) continue;
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
|
||||
Reference in new issue
Block a user