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

+3 -1
View File
@@ -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
+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
+17 -11
View File
@@ -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")
+92 -5
View File
@@ -77,6 +77,7 @@ if (!locale) {
const ALLENATORE = "g6";
const PARTITA = `${PREFISSO}-partita`;
const PRESENZE = `${PREFISSO}-presenze`;
const PRESENZE_CONVOCATI = `${PREFISSO}-presenze-convocati`;
const PALLONI_1 = `${PREFISSO}-palloni-1`;
const PALLONI_2 = `${PREFISSO}-palloni-2`;
const GENERICO = `${PREFISSO}-generico`;
@@ -150,10 +151,10 @@ if (!locale) {
/** A quali slot è arrivata una push dall'ultimo `svuota()`, senza ripetizioni e in ordine. */
const destinatariPush = () => [...new Set(ricevute)].sort();
type Notifica = { giocatore_id: string; titolo: string; tipo: string };
type Notifica = { giocatore_id: string; titolo: string; corpo: string; tipo: string };
const notifiche = (filtro: string) =>
leggi<Notifica>(
`notifiche_utente?${filtro}&select=giocatore_id,titolo,tipo&order=giocatore_id`,
`notifiche_utente?${filtro}&select=giocatore_id,titolo,corpo,tipo&order=giocatore_id`,
);
const slot = (righe: Notifica[]) => righe.map((r) => r.giocatore_id);
@@ -191,6 +192,7 @@ if (!locale) {
for (const [id, tipo, data] of [
[PARTITA, "partita", "2031-04-01"],
[PRESENZE, "allenamento", "2031-04-02"],
[PRESENZE_CONVOCATI, "partita", "2031-04-03"],
[PALLONI_1, "allenamento", "2031-05-01"],
[PALLONI_2, "allenamento", "2031-05-08"],
[GENERICO, "evento", "2031-05-09"],
@@ -201,7 +203,8 @@ if (!locale) {
titolo: `${PREFISSO} ${id}`,
data,
ora: "20:00",
convocati: [],
luogo: id === PRESENZE ? "PalaCRAP" : "",
convocati: id === PRESENZE_CONVOCATI ? [G_QUATTRO, G_CINQUE] : [],
});
}
@@ -373,6 +376,72 @@ if (!locale) {
},
);
await prova(
"il sollecito manuale ha il testo nuovo, con chi lo chiede e la risposta attuale",
async () => {
svuota();
const res = await chiama("sollecita-presenze", tokenAdmin, {
eventoId: PRESENZE,
da: "Mario Rossi",
});
assert.equal(res.status, 200);
const righe = await notifiche(`evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze`);
const cinque = righe.find((r) => r.giocatore_id === G_CINQUE);
const altro = righe.find((r) => r.giocatore_id !== G_CINQUE);
assert.equal(
cinque?.titolo,
`Conferma di partecipazione richiesta: ${PREFISSO} ${PRESENZE}`,
);
assert.equal(
cinque?.corpo,
[
"Data: 02/04/2031",
"Ora: 20:00",
"Luogo: PalaCRAP",
"Richiesta di: Mario Rossi",
"Risposta attuale: forse",
"Azione: indicare presente, assente o in ritardo",
].join("\n"),
);
assert.match(altro?.corpo ?? "", /Risposta attuale: nessuna/, "chi non ha risposto");
const conId = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${PRESENZE}&tipo=eq.sollecita_presenze&select=id`,
);
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${conId.map((r) => r.id).join(",")})&select=notifica_id`,
);
assert.equal(email.length, conId.length, "il sollecito manuale parte anche per email");
assert.equal(
righe.filter((r) => r.giocatore_id === G_CINQUE).length,
1,
"ripremere il pulsante riscrive la notifica, non la duplica",
);
assert.deepEqual(destinatariPush(), [G_CINQUE]);
},
);
await prova(
"il sollecito manuale segue i convocati, come quello automatico (DD-040)",
async () => {
const res = await chiama("sollecita-presenze", tokenAdmin, {
eventoId: PRESENZE_CONVOCATI,
});
assert.equal(res.status, 200);
const righe = await notifiche(
`evento_id=eq.${PRESENZE_CONVOCATI}&tipo=eq.sollecita_presenze`,
);
assert.deepEqual(
slot(righe),
[G_QUATTRO, G_CINQUE],
"solo i convocati: i non convocati non ricevono nulla, nemmeno senza risposta",
);
assert.ok(
!righe[0]!.corpo.includes("Richiesta di"),
"senza nome, senza la riga «Richiesta di»",
);
},
);
await prova("un giocatore non può sollecitare le presenze", async () => {
const res = await chiama("sollecita-presenze", giocatore.token, { eventoId: PRESENZE });
assert.ok(!res.ok, `stato ${res.status}`);
@@ -390,8 +459,25 @@ if (!locale) {
const righe = await notifiche(`evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni`);
assert.deepEqual(slot(righe), [G_UNO, G_DUE]);
const per = new Map(righe.map((r) => [r.giocatore_id, r.titolo]));
assert.equal(per.get(G_DUE), "Tocca a te prendere i palloni");
assert.equal(per.get(G_UNO), "Porta i palloni");
assert.equal(per.get(G_DUE), "Turno palloni: incarico assegnato");
assert.equal(per.get(G_UNO), "Turno palloni: riconsegna");
const corpoDue = righe.find((r) => r.giocatore_id === G_DUE)?.corpo ?? "";
assert.match(
corpoDue,
/^Evento: test-destinatari-notifiche test-destinatari-notifiche-palloni-2\n/,
);
assert.match(corpoDue, /\nData: 08\/05\/2031, ore 20:00\n/);
assert.match(corpoDue, /Incarico: custodia dei palloni al termine dell'evento/);
// DD-040: il turno palloni arriva come push e in-app, mai per email.
const conId = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${PALLONI_2}&tipo=eq.turno_palloni&select=id`,
);
assert.equal(conId.length, righe.length);
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${conId.map((r) => r.id).join(",")})&select=notifica_id`,
);
assert.equal(email.length, 0, "nessuna email per il turno palloni");
});
await prova(
@@ -427,6 +513,7 @@ if (!locale) {
for (const id of [
PARTITA,
PRESENZE,
PRESENZE_CONVOCATI,
PALLONI_1,
PALLONI_2,
GENERICO,
+10 -1
View File
@@ -1,6 +1,6 @@
/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */
import assert from "node:assert/strict";
import { formatData, giocatori, statoMeta } from "@/lib/crapp-data";
import { formatData, formatDataNumerica, giocatori, statoMeta } from "@/lib/crapp-data";
// --- rosa --------------------------------------------------------------------
assert.ok(giocatori.length > 0, "la rosa non è vuota");
@@ -29,6 +29,15 @@ for (const g of giocatori) {
// --- formatData --------------------------------------------------------------
assert.equal(formatData("2026-09-01"), "mar 01 settembre");
// --- formatDataNumerica: la data dei testi delle notifiche, come to_char(…, 'DD/MM/YYYY') ----
assert.equal(formatDataNumerica("2026-09-01"), "01/09/2026");
assert.equal(formatDataNumerica("2026-12-31"), "31/12/2026");
assert.equal(
formatDataNumerica("2026-03-29"),
"29/03/2026",
"nessuna conversione di fuso: il giorno del cambio ora resta quello scritto",
);
assert.equal(formatData("2026-01-31"), "sab 31 gennaio");
assert.ok(
!formatData("2026-03-29").includes("28"),
+18
View File
@@ -197,6 +197,24 @@ await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () =>
assert.equal(escapeHtml(`<&>"'`), "&lt;&amp;&gt;&quot;&#39;");
});
await prova(
"il corpo a righe etichettate delle notifiche automatiche (DD-040) resta leggibile",
() => {
const corpo = "Data: domani, 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
const m = componiMail(
notifica({
oggetto: "Promemoria evento di domani: Allenamento",
testo: corpo,
id_evento: "ev-1",
}),
OPZ,
);
assert.equal(m.subject, "Promemoria evento di domani: Allenamento");
assert.ok(m.text.startsWith(`${corpo}\n\nApri CrAPP: `), "in testo semplice le righe restano");
assert.match(m.html, /Data: domani, 01\/10\/2026<br>Ora: 21:00<br>Luogo: PalaCRAP/);
},
);
await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => {
const m = componiMail(notifica({ oggetto: "<b>Ciao</b>" }), OPZ);
assert.ok(!m.html.includes("<b>"));
+31
View File
@@ -1,10 +1,12 @@
/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
contatoreBadge,
daRiga,
pallinoNotifiche,
type RigaNotifica,
type TipoNotifica,
} from "@/lib/notifiche-utente";
// --- daRiga: conversione database -> modello applicativo ---------------------
@@ -48,4 +50,33 @@ assert.deepEqual(pallinoNotifiche(30, 12), { testo: "9+", daLeggere: true });
// Tutte lette: il pallino resta per poterle ancora aprire ed eliminare.
assert.deepEqual(pallinoNotifiche(3, 0), { testo: "3", daLeggere: false });
// --- i tipi automatici di DD-040 attraversano daRiga senza perdere le righe del testo -----
const tipiNuovi: TipoNotifica[] = [
"turno_palloni_12h",
"turno_palloni_6h",
"turno_palloni_3h",
"turno_palloni_revocato",
"sollecita_presenze_24h",
"sollecita_presenze_12h",
"sollecita_presenze_6h",
];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) {
const n = daRiga({ ...riga, tipo, corpo: corpoMultiriga });
assert.equal(n.tipo, tipo);
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
}
// Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
const uiBits = readFileSync(
new URL("../../src/components/crapp/ui-bits.tsx", import.meta.url),
"utf8",
);
assert.match(
uiBits,
/whitespace-pre-line[^"]*"[^>]*>\s*\{notifica\.corpo\}/,
"il corpo della notifica mantiene gli a capo",
);
console.log("notifiche-utente: ok");
+49 -3
View File
@@ -179,20 +179,46 @@ assert.deepEqual(
);
// --- avvisiPalloniEvento ------------------------------------------------------
// Il promemoria per un evento scelto dall'admin: due destinatari, con testi diversi.
// L'avviso per un evento scelto: due destinatari, con testi diversi (DD-040). Gli stessi testi dei
// job automatici del database: `test/integration/notifiche-automatiche.test.ts` li confronta.
const avvisi = avvisiPalloniEvento(turniPush, eventiPush, "p2");
assert.deepEqual(
avvisi.map((a) => a.giocatoreId),
["g2", "g1"],
"l'incaricato di questo evento, e chi ha i palloni dalla volta prima",
);
assert.match(avvisi[0]!.titolo, /prendere i palloni/);
assert.match(avvisi[1]!.titolo, /Porta i palloni/);
assert.equal(avvisi[0]!.titolo, "Turno palloni: incarico assegnato");
assert.equal(avvisi[1]!.titolo, "Turno palloni: riconsegna");
assert.equal(
avvisi[0]!.testo,
[
"Evento: Evento p2",
"Data: 02/02/2026, ore 21:00",
"Incarico: custodia dei palloni al termine dell'evento",
"Riconsegna: 03/02/2026",
].join("\n"),
"incarico: evento, data numerica, incarico e data di riconsegna dell'evento successivo",
);
assert.equal(
avvisi[1]!.testo,
[
"Evento: Evento p2",
"Data: 02/02/2026, ore 21:00",
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
].join("\n"),
"riconsegna: nessuna riga «Riconsegna», chi la riceve li riporta",
);
assert.ok(
avvisi.every((a) => !/oggi/i.test(a.testo)),
"il testo nomina l'evento, non 'oggi': può arrivare giorni prima",
);
const ultimo = avvisiPalloniEvento(turniPush, eventiPush, "p3");
assert.ok(
ultimo.every((a) => !a.testo.includes("Riconsegna:")),
"l'ultimo evento non ha un successivo: la riga «Riconsegna» manca",
);
assert.deepEqual(
avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId),
["g1"],
@@ -213,6 +239,26 @@ assert.deepEqual(
[],
"senza turni assegnati non c'è nessuno da avvisare",
);
assert.deepEqual(
avvisiPalloniEvento({ p1: "g1" }, eventiPush, "p2"),
[],
"senza un incaricato per l'evento non parte nulla, nemmeno la riconsegna (DD-040)",
);
// --- ordine: a parità di data conta l'ora, poi l'id --------------------------
const stessoGiorno: Evento[] = [
{ ...evento("z", "2026-06-01"), ora: "21:00" },
{ ...evento("y", "2026-06-01"), ora: "18:30" },
{ ...evento("x", "2026-06-01"), ora: "21:00" },
evento("a", "2026-05-31"),
];
assert.deepEqual(
eventiPalloni(stessoGiorno).map((e) => e.id),
["a", "y", "x", "z"],
"data, poi ora, poi id: lo stesso ordine dei job del database",
);
assert.equal(eventoPrecedente(stessoGiorno, "x")?.id, "y");
assert.equal(eventoSuccessivo(stessoGiorno, "y")?.id, "x");
// --- serieConsecutivaPalloni: volte consecutive in cui li ha portati lui -----
const eventiSerie: Evento[] = [
+60
View File
@@ -9,6 +9,7 @@ import {
includeInfortunato,
serieConferme,
serieConsecutiva,
testoSollecito,
totaliEventiGiocatore,
type MappaPresenze,
type MappaTempiRisposta,
@@ -158,6 +159,65 @@ assert.deepEqual(
);
assert.deepEqual(destinatariSollecito([], risposte), [], "rosa vuota, nessun destinatario");
// --- il sollecito segue i convocati (DD-040) ----------------------------------
assert.deepEqual(
destinatariSollecito(squadra, risposte, ["g1", "g2", "g3"]),
["g2", "g3"],
"solo i convocati senza risposta o con «forse»: g1 ha risposto, g4 non è convocato",
);
assert.deepEqual(
destinatariSollecito(squadra, risposte, ["g4"]),
[],
"l'unico convocato ha risposto: nessun sollecito",
);
assert.deepEqual(
destinatariSollecito(squadra, [], ["g3", "g5", "g6", "g9"]),
["g3"],
"convocati inattivi, allenatori o fuori rosa restano esclusi anche se convocati",
);
assert.deepEqual(
destinatariSollecito(squadra, risposte, []),
["g2", "g3"],
"nessun convocato indicato vale per tutta la rosa, come nel database",
);
// --- testo del sollecito -----------------------------------------------------
const eventoSollecito = {
titolo: "Allenamento del giovedì",
data: "2026-10-01",
ora: "21:00",
luogo: "PalaCRAP",
};
const automatico = testoSollecito(eventoSollecito, undefined);
assert.equal(automatico.titolo, "Conferma di partecipazione richiesta: Allenamento del giovedì");
assert.equal(
automatico.testo,
[
"Data: 01/10/2026",
"Ora: 21:00",
"Luogo: PalaCRAP",
"Risposta attuale: nessuna",
"Azione: indicare presente, assente o in ritardo",
].join("\n"),
);
assert.match(testoSollecito(eventoSollecito, "forse").testo, /Risposta attuale: forse/);
assert.match(
testoSollecito(eventoSollecito, "assente").testo,
/Risposta attuale: nessuna/,
"qualunque stato diverso da «forse» si legge come nessuna risposta",
);
const manuale = testoSollecito(eventoSollecito, undefined, "Mario Rossi");
assert.equal(
manuale.testo.split("\n")[3],
"Richiesta di: Mario Rossi",
"il sollecito manuale nomina chi lo chiede, tra il luogo e la risposta attuale",
);
assert.ok(!automatico.testo.includes("Richiesta di"), "l'automatico non ha un mittente");
assert.ok(
!testoSollecito({ ...eventoSollecito, luogo: " " }, undefined).testo.includes("Luogo:"),
"senza luogo la riga manca",
);
// --- serie conferme: risposta entro 24h dalla convocazione --------------------
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
...ev(id, "allenamento", data),