diff --git a/src/lib/palloni-core.ts b/src/lib/palloni-core.ts index b0131c2..e8188bb 100644 --- a/src/lib/palloni-core.ts +++ b/src/lib/palloni-core.ts @@ -1,4 +1,4 @@ -import { giocatori } from "./crapp-data"; +import { formatData, giocatori } from "./crapp-data"; import type { Evento } from "./eventi"; export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null }; @@ -32,17 +32,15 @@ export function completaTurni( } if (assegnato) return; - const scelto = giocatori - .slice() - .sort((a, b) => { - const ca = conteggio.get(a.id) ?? 0; - const cb = conteggio.get(b.id) ?? 0; - if (ca !== cb) return ca - cb; - const ua = ultimo.get(a.id) ?? -1; - const ub = ultimo.get(b.id) ?? -1; - if (ua !== ub) return ua - ub; - return a.nome.localeCompare(b.nome); - })[0]; + const scelto = giocatori.slice().sort((a, b) => { + const ca = conteggio.get(a.id) ?? 0; + const cb = conteggio.get(b.id) ?? 0; + if (ca !== cb) return ca - cb; + const ua = ultimo.get(a.id) ?? -1; + const ub = ultimo.get(b.id) ?? -1; + if (ua !== ub) return ua - ub; + return a.nome.localeCompare(b.nome); + })[0]; if (!scelto) return; risultato[evento.id] = scelto.id; @@ -83,3 +81,53 @@ export function oggiISO(): string { const dd = String(d.getDate()).padStart(2, "0"); return `${d.getFullYear()}-${mm}-${dd}`; } + +/** Chi deve ricevere l'avviso push, oggi: chi porta i palloni e chi li riprende. */ +export function destinatariPromemoriaPalloni( + turni: Record, + eventi: Evento[], + oggi: string, +): string[] { + const destinatari = new Set(); + for (const evento of eventiDelGiorno(eventi, oggi)) { + const incaricato = turni[evento.id]; + if (incaricato) destinatari.add(incaricato); + const prima = eventoPrecedente(eventi, evento.id); + const precedente = prima ? turni[prima.id] : undefined; + if (precedente) destinatari.add(precedente); + } + return [...destinatari]; +} + +/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */ +export function messaggioPalloniOggi( + turni: Record, + eventi: Evento[], + oggi: string, + mioId: string, + nome: string, +): { title: string; body: string } { + for (const evento of eventiDelGiorno(eventi, oggi)) { + const prima = eventoPrecedente(eventi, evento.id); + if (prima && turni[prima.id] === mioId) { + return { + title: "Porta i palloni oggi", + body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`, + }; + } + if (turni[evento.id] === mioId) { + const dopo = eventoSuccessivo(eventi, evento.id); + return { + title: "Tocca a te prendere i palloni", + body: dopo + ? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.` + : `A fine ${evento.titolo} porta a casa i palloni.`, + }; + } + } + + return { + title: "CrAPP · Turno palloni", + body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.", + }; +} diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts index 187ef77..7ecca3a 100644 --- a/src/routes/api/public/promemoria-palloni.ts +++ b/src/routes/api/public/promemoria-palloni.ts @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { completaTurni, eventiPalloni, eventoPrecedente, oggiISO } from "@/lib/palloni-core"; +import { completaTurni, destinatariPromemoriaPalloni, oggiISO } from "@/lib/palloni-core"; import { inviaPush } from "@/lib/webpush.server"; import { leggiEventi } from "@/lib/eventi.server"; @@ -18,22 +18,14 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ const turni = completaTurni(salvati, eventi); const oggi = oggiISO(); - const destinatari = new Set(); - for (const evento of eventiPalloni(eventi)) { - if (evento.data !== oggi) continue; - const incaricato = turni[evento.id]; - if (incaricato) destinatari.add(incaricato); - const prima = eventoPrecedente(eventi, evento.id); - const precedente = prima ? turni[prima.id] : undefined; - if (precedente) destinatari.add(precedente); - } + const destinatari = destinatariPromemoriaPalloni(turni, eventi, oggi); - if (destinatari.size === 0) return Response.json({ inviate: 0 }); + if (destinatari.length === 0) return Response.json({ inviate: 0 }); const { data: iscrizioni } = await supabaseAdmin .from("push_subscriptions") .select("endpoint, giocatore_id") - .in("giocatore_id", [...destinatari]); + .in("giocatore_id", destinatari); let inviate = 0; for (const iscrizione of iscrizioni ?? []) { @@ -56,4 +48,4 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ }, }, }, -}); \ No newline at end of file +}); diff --git a/src/routes/api/public/push-messaggio.ts b/src/routes/api/public/push-messaggio.ts index 2d66658..3907464 100644 --- a/src/routes/api/public/push-messaggio.ts +++ b/src/routes/api/public/push-messaggio.ts @@ -1,13 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; -import { formatData, giocatori } from "@/lib/crapp-data"; -import { - completaTurni, - eventiPalloni, - eventoPrecedente, - eventoSuccessivo, - oggiISO, -} from "@/lib/palloni-core"; +import { giocatori } from "@/lib/crapp-data"; +import { completaTurni, messaggioPalloniOggi, oggiISO } from "@/lib/palloni-core"; import { leggiEventi } from "@/lib/eventi.server"; const schema = z.object({ endpoint: z.string().url().max(1000) }); @@ -40,7 +34,8 @@ export const Route = createFileRoute("/api/public/push-messaggio")({ .eq("endpoint", parsed.data.endpoint) .maybeSingle(); - if (!iscrizione) return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." }); + if (!iscrizione) + return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." }); const { data: righe } = await supabaseAdmin .from("turni_palloni") @@ -54,31 +49,8 @@ export const Route = createFileRoute("/api/public/push-messaggio")({ const mioId = iscrizione.giocatore_id; const nome = giocatori.find((g) => g.id === mioId)?.nome ?? ""; - for (const evento of eventiPalloni(eventi)) { - if (evento.data !== oggi) continue; - const prima = eventoPrecedente(eventi, evento.id); - if (prima && turni[prima.id] === mioId) { - return Response.json({ - title: "Porta i palloni oggi", - body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`, - }); - } - if (turni[evento.id] === mioId) { - const dopo = eventoSuccessivo(eventi, evento.id); - return Response.json({ - title: "Tocca a te prendere i palloni", - body: dopo - ? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.` - : `A fine ${evento.titolo} porta a casa i palloni.`, - }); - } - } - - return Response.json({ - title: "CrAPP · Turno palloni", - body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.", - }); + return Response.json(messaggioPalloniOggi(turni, eventi, oggi, mioId, nome)); }, }, }, -}); \ No newline at end of file +}); diff --git a/test/unit/giocatori-squadra.test.ts b/test/unit/giocatori-squadra.test.ts new file mode 100644 index 0000000..cdf85a4 --- /dev/null +++ b/test/unit/giocatori-squadra.test.ts @@ -0,0 +1,119 @@ +/** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */ +import assert from "node:assert/strict"; +import { + dividiNome, + nomeCompleto, + numeroGiaUsato, + rosaFallback, + slotDi, + slotLiberi, + validaDatiSquadra, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; +import { giocatori } from "@/lib/crapp-data"; + +const riga = (parziale: Partial = {}): GiocatoreSquadra => ({ + id: "g1", + nome: "Mario", + cognome: "Rossi", + numero: 7, + ruolo: "schiacciatore", + authUserId: null, + attivo: true, + ...parziale, +}); + +// --- dividiNome ---------------------------------------------------------------- +assert.deepEqual(dividiNome("Carlo Di Castelnuovo"), { nome: "Carlo", cognome: "Di Castelnuovo" }); +assert.deepEqual(dividiNome("Mario Rossi"), { nome: "Mario", cognome: "Rossi" }); +assert.deepEqual( + dividiNome("Prince"), + { nome: "Prince", cognome: "" }, + "senza spazio: cognome vuoto", +); + +// --- rosaFallback ---------------------------------------------------------------- +const fallback = rosaFallback(); +assert.equal(fallback.length, giocatori.length, "un ingresso per ogni giocatore reale"); +assert.ok( + fallback.every((g) => g.attivo && g.authUserId === null), + "nessuno slot risulta collegato prima che il database risponda", +); +assert.deepEqual( + fallback.map((g) => g.id).sort(), + giocatori.map((g) => g.id).sort(), + "gli id combaciano con la rosa reale", +); + +// --- nomeCompleto ---------------------------------------------------------------- +assert.equal(nomeCompleto(riga()), "Mario Rossi"); +assert.equal( + nomeCompleto(riga({ cognome: "" })), + "Mario", + "senza cognome non resta uno spazio finale", +); + +// --- slotDi ---------------------------------------------------------------- +const righe: GiocatoreSquadra[] = [ + riga({ id: "g1", authUserId: "u1" }), + riga({ id: "g2", authUserId: null }), +]; +assert.equal(slotDi(righe, "u1")?.id, "g1"); +assert.equal(slotDi(righe, "u9"), null, "nessuno slot per un account non collegato"); +assert.equal(slotDi(righe, null), null, "senza sessione non c'è slot"); + +// --- slotLiberi ---------------------------------------------------------------- +const rosaMista: GiocatoreSquadra[] = [ + riga({ id: "g1", attivo: true, authUserId: null }), + riga({ id: "g2", attivo: true, authUserId: "u1" }), + riga({ id: "g3", attivo: false, authUserId: null }), +]; +assert.deepEqual( + slotLiberi(rosaMista).map((g) => g.id), + ["g1"], + "libero solo chi è attivo e senza account collegato", +); + +// --- validaDatiSquadra ---------------------------------------------------------------- +const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore" }; +assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore"); +assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i); +assert.match( + validaDatiSquadra({ ...datiOk, nome: " " })!, + /nome/i, + "spazi soli contano come vuoto", +); +assert.match(validaDatiSquadra({ ...datiOk, cognome: "" })!, /cognome/i); +assert.match(validaDatiSquadra({ ...datiOk, ruolo: "" })!, /ruolo/i); +assert.match( + validaDatiSquadra({ ...datiOk, numero: 0 })!, + /numero/i, + "zero non è un numero di maglia valido", +); +assert.match(validaDatiSquadra({ ...datiOk, numero: -3 })!, /numero/i); +assert.match( + validaDatiSquadra({ ...datiOk, numero: 4.5 })!, + /numero/i, + "il numero deve essere intero", +); + +// --- numeroGiaUsato ---------------------------------------------------------------- +const rosaNumeri: GiocatoreSquadra[] = [ + riga({ id: "g1", numero: 7, attivo: true }), + riga({ id: "g2", numero: 9, attivo: true }), + riga({ id: "g3", numero: 7, attivo: false }), +]; +assert.equal(numeroGiaUsato(rosaNumeri, "g2", 7), true, "il 7 è già di g1"); +assert.equal( + numeroGiaUsato(rosaNumeri, "g1", 7), + false, + "il proprio numero attuale non è un conflitto", +); +assert.equal( + numeroGiaUsato(rosaNumeri, "g4", 7), + true, + "conta solo il 7 attivo di g1, non serve escludere g3", +); +assert.equal(numeroGiaUsato(rosaNumeri, "g4", 11), false, "un numero libero non risulta usato"); + +console.log("giocatori-squadra: ok"); diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts index b14e20b..4c96f93 100644 --- a/test/unit/palloni-core.test.ts +++ b/test/unit/palloni-core.test.ts @@ -1,14 +1,16 @@ /** Check dei turni palloni: `bun src/lib/palloni-core.test.ts`. */ import assert from "node:assert/strict"; -import { giocatori } from "@/lib/crapp-data"; +import { formatData, giocatori } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; import { completaTurni, conteggioTurni, + destinatariPromemoriaPalloni, eventiDelGiorno, eventiPalloni, eventoPrecedente, eventoSuccessivo, + messaggioPalloniOggi, oggiISO, } from "@/lib/palloni-core"; @@ -95,4 +97,66 @@ assert.deepEqual(conteggioTurni({}), {}); assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/); assert.equal(oggiISO(), new Date().toLocaleDateString("sv-SE"), "data locale, non UTC"); +// --- destinatariPromemoriaPalloni -------------------------------------------- +const eventiPush: Evento[] = [ + evento("p1", "2026-02-01"), + evento("p2", "2026-02-02"), + evento("p3", "2026-02-03"), +]; +const turniPush = { p1: "g1", p2: "g2", p3: "g3" }; + +assert.deepEqual( + destinatariPromemoriaPalloni(turniPush, eventiPush, "2026-02-02"), + ["g2", "g1"], + "chi porta oggi e chi li aveva portati alla volta prima", +); +assert.deepEqual( + destinatariPromemoriaPalloni(turniPush, eventiPush, "2026-02-01"), + ["g1"], + "il primo evento non ha un precedente da avvisare", +); +assert.deepEqual( + destinatariPromemoriaPalloni(turniPush, eventiPush, "2026-03-01"), + [], + "nessun evento in quella data: nessun destinatario", +); +assert.deepEqual( + destinatariPromemoriaPalloni({ p1: "g1", p2: "g1" }, eventiPush, "2026-02-02"), + ["g1"], + "stessa persona oggi e alla volta prima: un solo avviso", +); + +// --- messaggioPalloniOggi ----------------------------------------------------- +assert.deepEqual( + messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"), + { + title: "Porta i palloni oggi", + body: "Evento p2 · 21:00. I palloni li hai tu dalla volta scorsa.", + }, + "chi li aveva alla volta prima deve riportarli oggi", +); +assert.deepEqual( + messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g2", "Luca"), + { + title: "Tocca a te prendere i palloni", + body: `A fine Evento p2 porta a casa i palloni e riportali il ${formatData("2026-02-03")}.`, + }, + "l'incaricato di oggi sa quando riportarli, se c'è un evento successivo", +); +assert.deepEqual( + messaggioPalloniOggi(turniPush, eventiPush, "2026-02-03", "g3", "Anna"), + { title: "Tocca a te prendere i palloni", body: "A fine Evento p3 porta a casa i palloni." }, + "senza evento successivo il messaggio non promette una data", +); +assert.deepEqual( + messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g9", "Sara"), + { title: "CrAPP · Turno palloni", body: "Sara, controlla il turno palloni nel calendario." }, + "chi non è coinvolto oggi riceve il messaggio generico col proprio nome", +); +assert.deepEqual( + messaggioPalloniOggi(turniPush, eventiPush, "2026-05-01", "g9", ""), + { title: "CrAPP · Turno palloni", body: "Controlla il calendario." }, + "senza eventi in quella data e senza nome noto, il messaggio resta generico", +); + console.log("palloni-core: ok");