Rimuove la serie «Conferme 24h» e i badge collegati

Toglie la serie dal profilo, dalla Home e dalle notifiche di traguardo, insieme
ai badge «Risposta lampo» e «Mai un forfait» e al calcolo di serieConferme.
La colonna risposto_il e il suo trigger restano nel database, inutilizzati.
Test e documentazione allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-06 13:21:14 +02:00
1 parent 8d0a8c7971
commit bfb768fb5a
17 files changed
+69 -718

No files matched your search

@@ -1,204 +0,0 @@
/**
* Badge segreto Mai un forfait end-to-end contro il database locale:
* `bun test/integration/s-mai-forfait-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()`, `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure. Qui invece
* si scrivono eventi con `creato_il` esplicito e risposte con `risposto_il` esplicito su
* `eventi_app`/`risposte_presenze`, si rileggono via REST e si passa il risultato attraverso
* entrambe le funzioni fino a `statoBadge()` sul badge segreto `s-mai-forfait`, che è l'unico a
* combinare due statistiche indipendenti (serie di conferme rapide **e** presenze totali).
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-mai-forfait-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { serieConferme, contaPresenzeGiocatore } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
if (!locale) {
salta("badge Mai un forfait sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-mai-forfait-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Mai un forfait su ${URL_BASE}`);
const PREFISSO = "test-s-mai-forfait-badge";
const def = badgeSegreti.find((b) => b.id === "s-mai-forfait")!;
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: `${dataEvento(i)}T08:00:00Z`,
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, i: number) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato: "presente",
risposto_il: `${dataEvento(i)}T08:30:00Z`,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenzeETempi(): Promise<{
presenze: MappaPresenze;
tempi: Record<string, Record<string, string>>;
}> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: Stato;
risposto_il: string;
}>;
const presenze: MappaPresenze = {};
const tempi: Record<string, Record<string, string>> = {};
for (const r of righe) {
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
(tempi[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
}
return { presenze, tempi };
}
function giocatoreAzzerato(serieConferme: number, presenze: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
presenze,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
};
}
try {
await prova(
"il segreto si sblocca solo quando entrambe le condizioni sono vere insieme",
async () => {
// "mf1": 15 eventi passati, presente e confermato in fretta a tutti — soddisfa
// ampiamente sia la serie di conferme (>=10) sia le presenze (>=15).
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "mf1", i);
}
const eventi15 = await leggiEventi();
const { presenze: p15, tempi: t15 } = await leggiPresenzeETempi();
// Solo 9 eventi: serie di conferme appena sotto soglia, presenze sotto soglia.
const eventi9 = eventi15.slice(0, 9);
const serie9 = serieConferme("mf1", eventi9, t15, OGGI);
const pres9 = contaPresenzeGiocatore("mf1", eventi9, p15, OGGI);
assert.equal(serie9, 9);
assert.equal(pres9, 9);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie9, pres9)).grado,
null,
"9 conferme e 9 presenze: nessuna delle due soglie raggiunta",
);
const serie15 = serieConferme("mf1", eventi15, t15, OGGI);
const pres15 = contaPresenzeGiocatore("mf1", eventi15, p15, OGGI);
assert.equal(serie15, 15);
assert.equal(pres15, 15);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie15, pres15)).grado,
"oro",
"serie conferme >=10 e presenze >=15: il segreto si sblocca",
);
},
);
await prova("una risposta lenta rompe la serie ma non le presenze già accumulate", async () => {
// Un sedicesimo evento con risposta arrivata oltre le 24h: la serie conferme torna a 0,
// ma le presenze (indipendenti) restano a 16 — il segreto deve richiudersi, non serve
// ripartire da zero anche sulle presenze.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: idLento,
giocatore_id: "mf1",
stato: "presente",
risposto_il: `${dataEvento(18)}T08:00:00Z`, // 2 giorni dopo la convocazione
}),
});
if (!res.ok)
throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
const eventi = await leggiEventi();
const { presenze, tempi } = await leggiPresenzeETempi();
const serie = serieConferme("mf1", eventi, tempi, OGGI);
const conto = contaPresenzeGiocatore("mf1", eventi, presenze, OGGI);
assert.equal(serie, 0, "la risposta lenta azzera la serie di conferme");
assert.equal(conto, 16, "le presenze restano quelle di sempre, indipendenti dalla serie");
assert.equal(
statoBadge(def, giocatoreAzzerato(serie, conto)).grado,
null,
"presenze abbondanti ma serie azzerata: il segreto si richiude",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-mai-forfait-badge");
}
+2 -2
View File
@@ -254,8 +254,8 @@ if (!locale) {
// --- presenze: la risposta si cambia, il cronometro no --------------------------
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
// risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
// risposta, `aggiornato_il` è l'ultima modifica; oggi nessuna statistica le usa (la serie
// «Conferme 24h» è stata rimossa). Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
await prova(
"la risposta di presenza si aggiorna senza far ripartire il cronometro",
@@ -1,229 +0,0 @@
/**
* Badge Risposta lampo end-to-end contro il database locale:
* `bun test/integration/serie-conferme-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
* mano. Qui invece si scrivono allenamenti/partite ed eventi veri su `eventi_app`, e risposte
* vere su `risposte_presenze` con `creato_il`/`risposto_il` espliciti, si rileggono via REST
* con la stessa forma di `daRiga()`/`fetchPresenze()`, e si passa il risultato attraverso
* `serieConferme()` fino a `statoBadge()` sul badge `serie-conferme`: se una colonna cambia
* nome, o la mappatura `creato_il`/`risposto_il` -> `creatoIl`/`tempi` si rompe, qui il grado
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
* mai il database.
*
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
* - una risposta arrivata oltre le 24h dalla convocazione azzera la serie, anche dopo una
* striscia lunga;
* - partite e allenamenti contano nella stessa serie, senza bisogno di un filtro per tipo.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-serie-conferme-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { serieConferme } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Risposta lampo sul database", "stack locale non attivo (npx supabase start)");
riepilogo("serie-conferme-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Risposta lampo su ${URL_BASE}`);
const PREFISSO = "test-serie-conferme-badge";
const serieDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così
// restano "passati" (quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
/** Istante di convocazione: le 8 del mattino del giorno dell'evento. */
const creazioneEvento = (i: number) => `${dataEvento(i)}T08:00:00Z`;
async function creaEvento(
id: string,
i: number,
tipo: "allenamento" | "partita" = "allenamento",
) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo,
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: creazioneEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(
eventoId: string,
giocatoreId: string,
stato: string,
rispostoIl: string,
) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato,
risposto_il: rispostoIl,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
/** Stessa forma di `fetchPresenze()`: eventoId -> giocatoreId -> istante della risposta. */
async function leggiTempi(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
risposto_il: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
return mappa;
}
function giocatoreAzzerato(serieConferme: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova(
"il badge passa da bronzo a oro con conferme rapide vere, e una risposta lenta lo azzera",
async () => {
// 15 eventi passati, tutti confermati mezz'ora dopo la convocazione per "sc1":
// bronzo (3), poi argento (8), poi oro (15) man mano che si rileggono i dati.
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "sc1", "presente", `${dataEvento(i)}T08:30:00Z`);
}
const eventi15 = await leggiEventi();
const tempi15 = await leggiTempi();
const serieA3 = serieConferme("sc1", eventi15.slice(0, 3), tempi15, OGGI);
assert.equal(serieA3, 3);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
"bronzo",
"3 conferme rapide di fila: soglia bronzo",
);
const serieA8 = serieConferme("sc1", eventi15.slice(0, 8), tempi15, OGGI);
assert.equal(serieA8, 8);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA8)).grado,
"argento",
"8 conferme rapide di fila: soglia argento",
);
const serieCompleta = serieConferme("sc1", eventi15, tempi15, OGGI);
assert.equal(serieCompleta, 15, "confermato in fretta a tutti e 15, nessun buco");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
"oro",
"15 conferme rapide di fila: soglia oro",
);
// Un sedicesimo evento con una risposta arrivata dopo 2 giorni azzera la serie.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
await rispondi(idLento, "sc1", "presente", `${dataEvento(18)}T08:00:00Z`);
const eventiConRitardo = await leggiEventi();
const tempiConRitardo = await leggiTempi();
const serieDopoRitardo = serieConferme("sc1", eventiConRitardo, tempiConRitardo, OGGI);
assert.equal(
serieDopoRitardo,
0,
"una risposta oltre le 24h azzera la serie, anche dopo 15 conferme di fila",
);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieDopoRitardo)).grado,
null,
"serie azzerata: nessun grado, si riparte da zero",
);
},
);
await prova(
"partite e allenamenti contano nella stessa serie, senza filtro per tipo",
async () => {
// "sc2": conferma rapida su un allenamento, poi su una partita, poi su un altro
// allenamento: la serie sale a 3 senza bisogno che siano tutti dello stesso tipo.
await creaEvento(`${PREFISSO}-b01`, 30, "allenamento");
await creaEvento(`${PREFISSO}-b02`, 31, "partita");
await creaEvento(`${PREFISSO}-b03`, 32, "allenamento");
await rispondi(`${PREFISSO}-b01`, "sc2", "presente", `${dataEvento(30)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b02`, "sc2", "presente", `${dataEvento(31)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b03`, "sc2", "presente", `${dataEvento(32)}T08:30:00Z`);
const eventiLetti = await leggiEventi();
const tempiLetti = await leggiTempi();
const serie = serieConferme("sc2", eventiLetti, tempiLetti, OGGI);
assert.equal(serie, 3, "partita in mezzo a due allenamenti: la serie resta a 3");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
"bronzo",
"3 conferme rapide di fila, tipo misto: soglia bronzo",
);
},
);
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("serie-conferme-badge");
}