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:
davideandClaude Sonnet 5.5 committed 2026-09-30 14:03:50 +02:00
1 parent 563142a30a
commit 9b4e3e4b45
12 files changed
+363 -48

No files matched your search

+6
View File
@@ -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" });
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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