From 689468141df1107749266aeda5ee5cacb6fa7cda Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 11 Sep 2026 14:58:27 +0200 Subject: [PATCH] Sposta roster, eventi, presenze, voti, scout e palloni su PocketBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Riscrive i moduli dati che parlavano con Supabase per usare l'SDK PocketBase, mantenendo invariate le firme degli hook React Query così i componenti a valle non cambiano: giocatori-squadra, eventi, presenze, cacche, pagelle, mvp-voti, badge-social, scout (store/live/stato), palloni. Aggiunge due helper condivisi in src/integrations/pocketbase/: - upsert.ts: PocketBase non ha upsert nativo, questi replicano il pattern onConflict di Supabase (per id significativo o per filtro su combinazione di campi), verificati contro un'istanza reale così da non duplicare record sulla stessa scrittura ripetuta; - formato.ts: normalizza le date PocketBase ("AAAA-MM-GG HH:MM:SS.sssZ", spazio non "T") a ISO stretto, altrimenti Date.parse e i confronti testuali con altre date si comportano in modo incoerente. Corregge anche due problemi trovati testando contro un'istanza reale: - alcune API rule confrontavano un campo relazione con @request.auth.id senza richiedere l'autenticazione, lasciando passare richieste anonime quando entrambi i lati erano vuoti; - gli hook non riconoscevano il superuser PocketBase (solo il ruolo admin applicativo), bloccando le operazioni fatte con pbAdmin. Aggiunta la colonna "casa" mancante su eventi_app e i campi di sistema created/updated (non generati automaticamente da PocketBase per le collection create via migration, a differenza di quanto assunto inizialmente). Co-Authored-By: Claude Sonnet 5 --- src/integrations/pocketbase/formato.ts | 8 ++ src/integrations/pocketbase/upsert.ts | 52 +++++++++++ src/lib/badge-social.ts | 41 ++++++-- src/lib/cacche.ts | 30 ++++-- src/lib/eventi.server.ts | 10 +- src/lib/eventi.ts | 72 +++++++------- src/lib/giocatori-squadra.server.ts | 20 ++-- src/lib/giocatori-squadra.ts | 124 ++++++++++++------------- src/lib/mvp-voti.ts | 38 ++++++-- src/lib/pagelle.ts | 38 ++++++-- src/lib/palloni.ts | 30 +++--- src/lib/presenze.ts | 56 ++++++----- src/lib/scout-live.ts | 92 ++++++++++++------ src/lib/scout-stato.ts | 46 +++++---- src/lib/scout-store.ts | 24 ++--- test/unit/eventi.test.ts | 24 ++--- 16 files changed, 430 insertions(+), 275 deletions(-) create mode 100644 src/integrations/pocketbase/formato.ts create mode 100644 src/integrations/pocketbase/upsert.ts diff --git a/src/integrations/pocketbase/formato.ts b/src/integrations/pocketbase/formato.ts new file mode 100644 index 0000000..d78c71c --- /dev/null +++ b/src/integrations/pocketbase/formato.ts @@ -0,0 +1,8 @@ +/** + * PocketBase restituisce i campi data/ora come "AAAA-MM-GG HH:MM:SS.sssZ" (spazio, non + * "T"): `Date.parse` su questo formato non è garantito coerente su tutti i motori JS. + * Normalizza a ISO 8601 stretto prima di qualsiasi confronto/calcolo di date. + */ +export function pbISO(v: string): string { + return v.includes("T") ? v : v.replace(" ", "T"); +} diff --git a/src/integrations/pocketbase/upsert.ts b/src/integrations/pocketbase/upsert.ts new file mode 100644 index 0000000..fb9793b --- /dev/null +++ b/src/integrations/pocketbase/upsert.ts @@ -0,0 +1,52 @@ +import PocketBase, { ClientResponseError, type RecordModel } from "pocketbase"; +import { pb } from "./client"; + +/** + * PocketBase non ha un upsert nativo. Per le collection con id significativo (es. `e1`, + * `g1`, dove l'id stesso è la chiave di unicità che prima era gestita da + * `onConflict: "id"`): aggiorna se il record esiste, altrimenti lo crea con quell'id. + * + * `client` di default è il client browser (`pb`): passare esplicitamente `pbAdmin()` nei + * moduli server-side che oggi usano `supabaseAdmin`. + */ +export async function upsertById( + collection: string, + id: string, + dati: Record, + client: PocketBase = pb, +): Promise { + try { + return await client.collection(collection).update(id, dati); + } catch (errore) { + if (errore instanceof ClientResponseError && errore.status === 404) { + return await client.collection(collection).create({ id, ...dati }); + } + throw errore; + } +} + +/** + * Per le collection dove l'unicità è su una combinazione di campi (es. + * `evento_id,giocatore_id`, prima gestita da un indice UNIQUE + `onConflict`) e l'id del + * record è generato da PocketBase, non ha significato applicativo: cerca il record che + * combacia col filtro e lo aggiorna, altrimenti ne crea uno nuovo. + */ +export async function upsertByFilter( + collection: string, + filtro: string, + parametri: Record, + dati: Record, + client: PocketBase = pb, +): Promise { + try { + const esistente = await client + .collection(collection) + .getFirstListItem(client.filter(filtro, parametri)); + return await client.collection(collection).update((esistente as RecordModel).id, dati); + } catch (errore) { + if (errore instanceof ClientResponseError && errore.status === 404) { + return await client.collection(collection).create(dati); + } + throw errore; + } +} diff --git a/src/lib/badge-social.ts b/src/lib/badge-social.ts index 2f96d02..8f8cef8 100644 --- a/src/lib/badge-social.ts +++ b/src/lib/badge-social.ts @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { HandHeart, Handshake, Laugh, Scale, Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; export type CategoriaSocial = { id: string; @@ -58,6 +59,14 @@ export type VotoSocial = { votato_nome: string; }; +type RigaSocialPocketBase = { + evento: string; + categoria: string; + votante: string; + votato: string; + votato_nome: string; +}; + const CHIAVE = ["badge-social-voti"] as const; /** Tutti i voti social (poche righe): nessun polling, cache lunga. */ @@ -66,11 +75,14 @@ export function useVotiSocial() { queryKey: CHIAVE, staleTime: 10 * 60_000, queryFn: async (): Promise => { - const { data, error } = await supabase - .from("badge_social_voti") - .select("match_id, categoria, votante_id, votato_id, votato_nome"); - if (error) throw error; - return (data ?? []) as VotoSocial[]; + const righe = await pb.collection("badge_social_voti").getFullList(); + return righe.map((r) => ({ + match_id: r.evento, + categoria: r.categoria, + votante_id: r.votante, + votato_id: r.votato, + votato_nome: r.votato_nome, + })); }, }); } @@ -79,10 +91,19 @@ export function useVotaSocial() { const qc = useQueryClient(); return useMutation({ mutationFn: async (voto: VotoSocial) => { - const { error } = await supabase - .from("badge_social_voti") - .upsert(voto, { onConflict: "match_id,categoria,votante_id" }); - if (error) throw error; + // No autovoto e convocazione li valida l'hook voti_convocati.pb.js. + await upsertByFilter( + "badge_social_voti", + "evento = {:evento} && categoria = {:categoria} && votante = {:votante}", + { evento: voto.match_id, categoria: voto.categoria, votante: voto.votante_id }, + { + evento: voto.match_id, + categoria: voto.categoria, + votante: voto.votante_id, + votato: voto.votato_id, + votato_nome: voto.votato_nome, + }, + ); return voto; }, // Aggiornamento locale della cache: zero riletture. diff --git a/src/lib/cacche.ts b/src/lib/cacche.ts index 42577d3..8653f05 100644 --- a/src/lib/cacche.ts +++ b/src/lib/cacche.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; import { oggiISO } from "./palloni-core"; /** Ora di apertura del sondaggio, il giorno stesso della partita. */ @@ -48,6 +49,12 @@ export type RigaCacche = { quantita: number; }; +type RigaCacchePocketBase = { + evento: string; + giocatore: string; + quantita: number; +}; + export const CACCHE_KEY = ["cacche"] as const; export function useCacche() { @@ -55,11 +62,12 @@ export function useCacche() { queryKey: CACCHE_KEY, staleTime: 10 * 60_000, queryFn: async (): Promise => { - const { data, error } = await supabase - .from("cacche_partita") - .select("evento_id, giocatore_id, quantita"); - if (error) throw error; - return (data ?? []) as RigaCacche[]; + const righe = await pb.collection("cacche_partita").getFullList(); + return righe.map((r) => ({ + evento_id: r.evento, + giocatore_id: r.giocatore, + quantita: r.quantita, + })); }, }); return { ...query, righe: query.data ?? [] }; @@ -69,10 +77,12 @@ export function useSalvaCacche() { const qc = useQueryClient(); return useMutation({ mutationFn: async (riga: RigaCacche) => { - const { error } = await supabase - .from("cacche_partita") - .upsert(riga, { onConflict: "evento_id,giocatore_id" }); - if (error) throw error; + await upsertByFilter( + "cacche_partita", + "evento = {:evento} && giocatore = {:giocatore}", + { evento: riga.evento_id, giocatore: riga.giocatore_id }, + { evento: riga.evento_id, giocatore: riga.giocatore_id, quantita: riga.quantita }, + ); return riga; }, onSuccess: (riga) => { diff --git a/src/lib/eventi.server.ts b/src/lib/eventi.server.ts index 7012441..2047301 100644 --- a/src/lib/eventi.server.ts +++ b/src/lib/eventi.server.ts @@ -1,11 +1,9 @@ import { daRiga, type Evento, type RigaEvento } from "./eventi"; -const COLONNE = - "id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse"; - /** Lettura eventi lato server (route API): stessa conversione del client. */ export async function leggiEventi(): Promise { - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { data } = await supabaseAdmin.from("eventi_app").select(COLONNE).order("data"); - return ((data ?? []) as RigaEvento[]).map(daRiga); + const { pbAdmin } = await import("@/integrations/pocketbase/client.server"); + const admin = await pbAdmin(); + const righe = await admin.collection("eventi_app").getFullList({ sort: "data" }); + return righe.map(daRiga); } diff --git a/src/lib/eventi.ts b/src/lib/eventi.ts index cfe3ba9..8f112b3 100644 --- a/src/lib/eventi.ts +++ b/src/lib/eventi.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { pbISO } from "@/integrations/pocketbase/formato"; +import { upsertById } from "@/integrations/pocketbase/upsert"; import type { Giocatore } from "./crapp-data"; export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno"; @@ -25,6 +27,7 @@ export type Evento = { creatoIl?: string | undefined; }; +/** Riga così come la restituisce PocketBase. */ export type RigaEvento = { id: string; tipo: string; @@ -32,35 +35,37 @@ export type RigaEvento = { luogo: string; data: string; ora: string; - note: string | null; - convocati: string[] | null; + note: string; + convocati: string[]; campionato: boolean; - casa: boolean | null; + casa: boolean; pagelle_chiuse: boolean; - creato_il?: string; + created?: string; }; +/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */ +function soloData(v: string): string { + return v.slice(0, 10); +} + /** Conversione riga database -> modello applicativo (riusabile anche lato server). */ export function daRiga(r: RigaEvento): Evento { return { id: r.id, - tipo: (r.tipo as EventoTipo) ?? "evento", + tipo: (r.tipo as EventoTipo) || "evento", titolo: r.titolo, - luogo: r.luogo ?? "", - data: r.data, - ora: r.ora ?? "", - note: r.note ?? "", + luogo: r.luogo || "", + data: soloData(r.data), + ora: r.ora || "", + note: r.note || "", convocati: r.convocati ?? [], campionato: !!r.campionato, - casa: r.casa ?? true, + casa: r.casa, pagelleChiuse: !!r.pagelle_chiuse, - creatoIl: r.creato_il, + creatoIl: r.created ? pbISO(r.created) : undefined, }; } -const COLONNE = - "id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il"; - /** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */ export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento"; @@ -79,9 +84,8 @@ export function daCategoria(c: CategoriaEvento): Pick { - const { data, error } = await supabase.from("eventi_app").select(COLONNE).order("data"); - if (error) throw error; - return ((data ?? []) as RigaEvento[]).map(daRiga); + const righe = await pb.collection("eventi_app").getFullList({ sort: "data" }); + return righe.map(daRiga); } /** Una lettura per sessione: il calendario cambia raramente. */ @@ -119,23 +123,18 @@ export function useSalvaEvento() { const qc = useQueryClient(); return useMutation({ mutationFn: async (evento: Evento) => { - const { error } = await supabase.from("eventi_app").upsert( - { - id: evento.id, - tipo: evento.tipo, - titolo: evento.titolo, - luogo: evento.luogo, - data: evento.data, - ora: evento.ora, - note: evento.note, - convocati: evento.convocati, - campionato: evento.campionato, - casa: evento.casa, - pagelle_chiuse: evento.pagelleChiuse, - }, - { onConflict: "id" }, - ); - if (error) throw error; + await upsertById("eventi_app", evento.id, { + tipo: evento.tipo, + titolo: evento.titolo, + luogo: evento.luogo, + data: evento.data, + ora: evento.ora, + note: evento.note, + convocati: evento.convocati, + campionato: evento.campionato, + casa: evento.casa, + pagelle_chiuse: evento.pagelleChiuse, + }); return evento; }, // Aggiornamento locale della cache: nessuna rilettura dal database. @@ -152,8 +151,7 @@ export function useEliminaEvento() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { - const { error } = await supabase.from("eventi_app").delete().eq("id", id); - if (error) throw error; + await pb.collection("eventi_app").delete(id); return id; }, onSuccess: (id) => { diff --git a/src/lib/giocatori-squadra.server.ts b/src/lib/giocatori-squadra.server.ts index 5fef7bb..995ade2 100644 --- a/src/lib/giocatori-squadra.server.ts +++ b/src/lib/giocatori-squadra.server.ts @@ -1,20 +1,16 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; import { - COLONNE_SQUADRA, daRigaSquadra, type GiocatoreSquadra, type RigaGiocatoreSquadra, } from "./giocatori-squadra"; -/** Lettura squadra lato server (route API): stessa conversione del client. */ +/** Lettura squadra lato server (route API): usa il superuser per non dipendere dalla + * sessione del chiamante, stessa conversione del client. */ export async function leggiGiocatoriSquadra(): Promise { - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - // `types.ts` non include ancora `giocatori_squadra` con le colonne di M8 (vedi client-nuove-tabelle.ts). - const client = supabaseAdmin as unknown as SupabaseClient; - const { data } = await client - .from("giocatori_squadra") - .select(COLONNE_SQUADRA) - .order("cognome") - .order("nome"); - return ((data ?? []) as RigaGiocatoreSquadra[]).map(daRigaSquadra); + const { pbAdmin } = await import("@/integrations/pocketbase/client.server"); + const admin = await pbAdmin(); + const righe = await admin.collection("giocatori_squadra").getFullList({ + sort: "cognome,nome", + }); + return righe.map(daRigaSquadra); } diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts index 469b348..2f4835b 100644 --- a/src/lib/giocatori-squadra.ts +++ b/src/lib/giocatori-squadra.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; +import { pb } from "@/integrations/pocketbase/client"; import { dividiNome, giocatori } from "./crapp-data"; /** @@ -20,17 +20,18 @@ export type GiocatoreSquadra = { dataTessera: string | null; }; +/** Riga così come la restituisce PocketBase: le relazioni/campi vuoti sono "", mai null. */ export type RigaGiocatoreSquadra = { id: string; nome: string; cognome: string; numero: number; ruolo: string; - auth_user_id: string | null; + auth_user_id: string; attivo: boolean; - email: string | null; - numero_tessera: string | null; - data_tessera: string | null; + email: string; + numero_tessera: string; + data_tessera: string; }; /** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */ @@ -76,10 +77,18 @@ export function slotPerEmail( return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null; } -export const COLONNE_SQUADRA = - "id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera"; +/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */ +function vuotoANull(v: string): string | null { + return v ? v : null; +} -/** Conversione riga database -> modello applicativo (riusabile anche lato server). */ +/** I campi "date" di PocketBase tornano come datetime completo ("2026-01-01 00:00:00.000Z"): + * qui serve solo "YYYY-MM-DD" (input HTML type="date", confronti testuali con altre date). */ +function soloData(v: string): string | null { + return v ? v.slice(0, 10) : null; +} + +/** Conversione riga PocketBase -> modello applicativo (riusabile anche lato server). */ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { return { id: r.id, @@ -87,22 +96,18 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { cognome: r.cognome, numero: r.numero, ruolo: r.ruolo, - authUserId: r.auth_user_id, + authUserId: vuotoANull(r.auth_user_id), attivo: r.attivo, - email: r.email, - numeroTessera: r.numero_tessera, - dataTessera: r.data_tessera, + email: vuotoANull(r.email), + numeroTessera: vuotoANull(r.numero_tessera), + dataTessera: soloData(r.data_tessera), }; } async function fetchSquadra(): Promise { - const { data, error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .select(COLONNE_SQUADRA) - .order("cognome") - .order("nome"); - if (error) throw error; - const righe = (data ?? []) as RigaGiocatoreSquadra[]; + const righe = await pb.collection("giocatori_squadra").getFullList({ + sort: "cognome,nome", + }); return righe.map(daRigaSquadra); } @@ -118,8 +123,8 @@ export function useGiocatoriSquadra() { export type DatiSquadra = Pick; /** - * Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori): - * meglio dirlo qui che far tornare un errore Postgres all'utente. + * Controlli che rispecchiano i vincoli della collection (`numero > 0`, campi obbligatori): + * meglio dirlo qui che far tornare un errore di PocketBase all'utente. * Restituisce il messaggio da mostrare, oppure `null` se va bene. */ export function validaDatiSquadra(dati: DatiSquadra): string | null { @@ -132,7 +137,7 @@ export function validaDatiSquadra(dati: DatiSquadra): string | null { return null; } -/** Il prossimo id libero nel formato `g` richiesto dal vincolo della tabella. */ +/** Il prossimo id libero nel formato `g` richiesto dal vincolo della collection. */ export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string { const max = righe.reduce((acc, g) => { const n = Number(g.id.slice(1)); @@ -150,7 +155,7 @@ export function numeroGiaUsato( return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero); } -/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */ +/** Modifica dei dati squadra. Solo un admin passa le API rules di M1. */ export function useSalvaDatiSquadra() { const queryClient = useQueryClient(); return useMutation({ @@ -160,14 +165,10 @@ export function useSalvaDatiSquadra() { cognome: input.dati.cognome.trim(), numero: input.dati.numero, ruolo: input.dati.ruolo.trim(), - email: input.dati.email?.trim() || null, + email: input.dati.email?.trim() || "", }; - const { error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .update(dati) - .eq("id", input.giocatoreId); - if (error) throw error; - return { giocatoreId: input.giocatoreId, dati }; + await pb.collection("giocatori_squadra").update(input.giocatoreId, dati); + return { giocatoreId: input.giocatoreId, dati: { ...dati, email: dati.email || null } }; }, onSuccess: (input) => { queryClient.setQueryData(SQUADRA_KEY, (prec) => @@ -182,7 +183,7 @@ export type DatiTesseramento = Pick { const dati = { - numero_tessera: input.dati.numeroTessera?.trim() || null, - data_tessera: input.dati.dataTessera || null, + numero_tessera: input.dati.numeroTessera?.trim() || "", + data_tessera: input.dati.dataTessera || "", }; - const { error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .update(dati) - .eq("id", input.giocatoreId); - if (error) throw error; + await pb.collection("giocatori_squadra").update(input.giocatoreId, dati); return { giocatoreId: input.giocatoreId, - dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera }, + dati: { + numeroTessera: vuotoANull(dati.numero_tessera), + dataTessera: vuotoANull(dati.data_tessera), + }, }; }, onSuccess: (input) => { @@ -212,7 +212,7 @@ export function useSalvaTesseramento() { } /** - * Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1. + * Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le API rules di M1. * L'id (`g`) non è generato dal database: va calcolato con `prossimoIdGiocatore` * prima di chiamare questa mutazione. */ @@ -226,15 +226,20 @@ export function useAggiungiGiocatore() { cognome: input.dati.cognome.trim(), numero: input.dati.numero, ruolo: input.dati.ruolo.trim(), - email: input.dati.email?.trim() || null, + email: input.dati.email?.trim() || "", + attivo: true, + }; + await pb.collection("giocatori_squadra").create(riga); + return { + ...riga, + email: riga.email || null, + authUserId: null, + numeroTessera: null, + dataTessera: null, }; - const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga); - if (error) throw error; - // Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL. - return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null }; }, // Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query - // (`.order("cognome").order("nome")`). + // (sort "cognome,nome"). onSuccess: (nuovo) => { queryClient.setQueryData(SQUADRA_KEY, (prec) => [...(prec ?? []), nuovo].sort( @@ -253,11 +258,7 @@ export function useImpostaAttivo() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => { - const { error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .update({ attivo: input.attivo }) - .eq("id", input.giocatoreId); - if (error) throw error; + await pb.collection("giocatori_squadra").update(input.giocatoreId, { attivo: input.attivo }); return input; }, onSuccess: (input) => { @@ -276,11 +277,7 @@ export function useScollegaAccount() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (giocatoreId: string) => { - const { error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .update({ auth_user_id: null }) - .eq("id", giocatoreId); - if (error) throw error; + await pb.collection("giocatori_squadra").update(giocatoreId, { auth_user_id: "" }); return giocatoreId; }, onSuccess: (giocatoreId) => { @@ -292,20 +289,17 @@ export function useScollegaAccount() { } /** - * Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se - * lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel - * database, non qui. + * Collega l'account al giocatore scelto. L'hook giocatori_squadra_claim.pb.js accetta + * l'operazione solo se lo slot è libero, l'email combacia e nessun altro campo cambia + * (DD-016 regola 2): il vincolo vive nel database, non qui. */ export function useCollegaGiocatore() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { giocatoreId: string; utenteId: string }) => { - const { error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .update({ auth_user_id: input.utenteId }) - .eq("id", input.giocatoreId) - .is("auth_user_id", null); - if (error) throw error; + await pb + .collection("giocatori_squadra") + .update(input.giocatoreId, { auth_user_id: input.utenteId }); return input; }, onSuccess: (input) => { diff --git a/src/lib/mvp-voti.ts b/src/lib/mvp-voti.ts index d7ebdcc..b32e4a1 100644 --- a/src/lib/mvp-voti.ts +++ b/src/lib/mvp-voti.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; export type VotoMvp = { match_id: string; @@ -8,6 +9,13 @@ export type VotoMvp = { votato_nome: string; }; +type RigaMvpPocketBase = { + evento: string; + votante: string; + votato: string; + votato_nome: string; +}; + const CHIAVE = ["mvp-voti"] as const; /** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */ @@ -27,11 +35,13 @@ export function useVotiMvp() { queryKey: CHIAVE, staleTime: 10 * 60_000, queryFn: async (): Promise => { - const { data, error } = await supabase - .from("mvp_voti") - .select("match_id, votante_id, votato_id, votato_nome"); - if (error) throw error; - return (data ?? []) as VotoMvp[]; + const righe = await pb.collection("mvp_voti").getFullList(); + return righe.map((r) => ({ + match_id: r.evento, + votante_id: r.votante, + votato_id: r.votato, + votato_nome: r.votato_nome, + })); }, }); } @@ -40,10 +50,18 @@ export function useVotaMvp() { const qc = useQueryClient(); return useMutation({ mutationFn: async (voto: VotoMvp) => { - const { error } = await supabase - .from("mvp_voti") - .upsert(voto, { onConflict: "match_id,votante_id" }); - if (error) throw error; + // No autovoto e convocazione li valida l'hook voti_convocati.pb.js. + await upsertByFilter( + "mvp_voti", + "evento = {:evento} && votante = {:votante}", + { evento: voto.match_id, votante: voto.votante_id }, + { + evento: voto.match_id, + votante: voto.votante_id, + votato: voto.votato_id, + votato_nome: voto.votato_nome, + }, + ); return voto; }, // Aggiorna la cache localmente: nessuna rilettura dal database. diff --git a/src/lib/pagelle.ts b/src/lib/pagelle.ts index 8e56a18..434b8f7 100644 --- a/src/lib/pagelle.ts +++ b/src/lib/pagelle.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; import { VOTI_MINIMI_PAGELLA } from "./badges"; /** Voto anonimo da 1 a 10 dato a un compagno per una partita. */ @@ -10,6 +11,13 @@ export type VotoPagella = { voto: number; }; +type RigaPagellaPocketBase = { + evento: string; + votante: string; + votato: string; + voto: number; +}; + export const PAGELLE_KEY = ["pagelle"] as const; /** Poche righe per stagione: una lettura per sessione basta. */ @@ -18,11 +26,13 @@ export function usePagelle() { queryKey: PAGELLE_KEY, staleTime: 10 * 60_000, queryFn: async (): Promise => { - const { data, error } = await supabase - .from("pagelle_voti") - .select("match_id, votante_id, votato_id, voto"); - if (error) throw error; - return (data ?? []) as VotoPagella[]; + const righe = await pb.collection("pagelle_voti").getFullList(); + return righe.map((r) => ({ + match_id: r.evento, + votante_id: r.votante, + votato_id: r.votato, + voto: r.voto, + })); }, }); return { ...query, voti: query.data ?? [] }; @@ -32,10 +42,18 @@ export function useVotaPagella() { const qc = useQueryClient(); return useMutation({ mutationFn: async (voto: VotoPagella) => { - const { error } = await supabase - .from("pagelle_voti") - .upsert(voto, { onConflict: "match_id,votante_id,votato_id" }); - if (error) throw error; + // Convocazione e "pagelle non chiuse" li valida l'hook voti_convocati.pb.js. + await upsertByFilter( + "pagelle_voti", + "evento = {:evento} && votante = {:votante} && votato = {:votato}", + { evento: voto.match_id, votante: voto.votante_id, votato: voto.votato_id }, + { + evento: voto.match_id, + votante: voto.votante_id, + votato: voto.votato_id, + voto: voto.voto, + }, + ); return voto; }, // Cache aggiornata localmente: nessuna rilettura. diff --git a/src/lib/palloni.ts b/src/lib/palloni.ts index 9b49067..1ad3310 100644 --- a/src/lib/palloni.ts +++ b/src/lib/palloni.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; import { completaTurni } from "./palloni-core"; import { useEventi } from "./eventi"; import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; @@ -7,18 +8,15 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; export const TURNI_KEY = ["turni-palloni"] as const; type RigaTurno = { - evento_id: string; - giocatore_id: string; - aggiornato_da: string | null; + evento: string; + giocatore: string; + aggiornato_da: string; }; async function fetchTurni(): Promise> { - const { data, error } = await supabase - .from("turni_palloni") - .select("evento_id, giocatore_id, aggiornato_da"); - if (error) throw error; + const righe = await pb.collection("turni_palloni").getFullList(); const mappa: Record = {}; - for (const riga of (data ?? []) as RigaTurno[]) mappa[riga.evento_id] = riga.giocatore_id; + for (const riga of righe) mappa[riga.evento] = riga.giocatore; return mappa; } @@ -37,16 +35,16 @@ export function useAssegnaTurno() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { eventoId: string; giocatoreId: string; da: string | null }) => { - const { error } = await supabase.from("turni_palloni").upsert( + await upsertByFilter( + "turni_palloni", + "evento = {:evento}", + { evento: input.eventoId }, { - evento_id: input.eventoId, - giocatore_id: input.giocatoreId, - aggiornato_da: input.da, - aggiornato_il: new Date().toISOString(), + evento: input.eventoId, + giocatore: input.giocatoreId, + aggiornato_da: input.da ?? "", }, - { onConflict: "evento_id" }, ); - if (error) throw error; return input; }, // Scrittura unica + aggiornamento cache locale, senza rilettura. diff --git a/src/lib/presenze.ts b/src/lib/presenze.ts index a2f3cc7..c00b5fd 100644 --- a/src/lib/presenze.ts +++ b/src/lib/presenze.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; +import { pbISO } from "@/integrations/pocketbase/formato"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; import type { Stato } from "./crapp-data"; import type { Evento } from "./eventi"; import { aggiornaSerie } from "./serie"; @@ -13,6 +15,14 @@ export type MappaPresenze = Record>; /** eventoId -> giocatoreId -> istante della prima risposta (ISO). */ export type MappaTempiRisposta = Record>; +type RigaPresenza = { + id: string; + evento: string; + giocatore: string; + stato: string; + risposto_il: string; +}; + /** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */ function eventiContanoPresenze( eventi: Evento[], @@ -158,15 +168,12 @@ function serieSu( type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta }; async function fetchPresenze(): Promise { - const { data, error } = await supabase - .from("risposte_presenze") - .select("evento_id, giocatore_id, stato, risposto_il"); - if (error) throw error; + const righe = await pb.collection("risposte_presenze").getFullList(); const presenze: MappaPresenze = {}; const tempi: MappaTempiRisposta = {}; - for (const riga of data ?? []) { - (presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato; - (tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il; + for (const riga of righe) { + (presenze[riga.evento] ??= {})[riga.giocatore] = riga.stato as Stato; + (tempi[riga.evento] ??= {})[riga.giocatore] = pbISO(riga.risposto_il); } return { presenze, tempi }; } @@ -188,7 +195,7 @@ export function usePresenzeEvento(eventoId: string) { * Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`): * * - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il` - * non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima, + * non viene inviato sulla scrittura e un hook lo congela: è la prima risposta, non l'ultima, * e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h"; * - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore * risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta. @@ -219,23 +226,24 @@ export function useSalvaPresenza() { return useMutation({ mutationFn: async (input: { eventoId: string; giocatoreId: string; stato: Stato | null }) => { if (input.stato === null) { - const { error } = await supabase - .from("risposte_presenze") - .delete() - .eq("evento_id", input.eventoId) - .eq("giocatore_id", input.giocatoreId); - if (error) throw error; + const esistente = await pb + .collection("risposte_presenze") + .getFirstListItem( + pb.filter("evento = {:evento} && giocatore = {:giocatore}", { + evento: input.eventoId, + giocatore: input.giocatoreId, + }), + ) + .catch(() => null); + if (esistente) await pb.collection("risposte_presenze").delete(esistente.id); } else { - const { error } = await supabase.from("risposte_presenze").upsert( - { - evento_id: input.eventoId, - giocatore_id: input.giocatoreId, - stato: input.stato, - aggiornato_il: new Date().toISOString(), - }, - { onConflict: "evento_id,giocatore_id" }, + // risposto_il NON va inviato: lo valorizza/congela l'hook risposte_presenze_immutabili.pb.js. + await upsertByFilter( + "risposte_presenze", + "evento = {:evento} && giocatore = {:giocatore}", + { evento: input.eventoId, giocatore: input.giocatoreId }, + { evento: input.eventoId, giocatore: input.giocatoreId, stato: input.stato }, ); - if (error) throw error; } return input; }, diff --git a/src/lib/scout-live.ts b/src/lib/scout-live.ts index 2e739db..725df09 100644 --- a/src/lib/scout-live.ts +++ b/src/lib/scout-live.ts @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { ClientResponseError } from "pocketbase"; +import { pb } from "@/integrations/pocketbase/client"; +import { pbISO } from "@/integrations/pocketbase/formato"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; import { useEventi, type Evento } from "./eventi"; /** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */ @@ -29,6 +32,23 @@ export type SessioneScout = { aggiornato_il: string; }; +type RigaSessionePocketBase = { + id: string; + evento: string; + giocatore: string; + giocatore_nome: string; + updated: string; +}; + +function daRigaSessione(r: RigaSessionePocketBase): SessioneScout { + return { + evento_id: r.evento, + giocatore_id: r.giocatore, + giocatore_nome: r.giocatore_nome, + aggiornato_il: pbISO(r.updated), + }; +} + export function sessioneScaduta(s: SessioneScout | null): boolean { if (!s) return true; const aggiornato = new Date(s.aggiornato_il).getTime(); @@ -41,13 +61,17 @@ export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] a /** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */ async function leggiSessione(eventoId: string): Promise { - const { data, error } = await supabase - .from("scout_sessioni") - .select("evento_id, giocatore_id, giocatore_nome, aggiornato_il") - .eq("evento_id", eventoId) - .maybeSingle(); - if (error) throw error; - return data; + try { + const riga = await pb + .collection("scout_sessioni") + .getFirstListItem( + pb.filter("evento = {:evento}", { evento: eventoId }), + ); + return daRigaSessione(riga); + } catch (errore) { + if (errore instanceof ClientResponseError && errore.status === 404) return null; + throw errore; + } } export function useSessioneScout(eventoId: string | null) { @@ -70,16 +94,12 @@ export function useApriSessioneScout() { if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) { return false; } - const { error } = await supabase.from("scout_sessioni").upsert( - { - evento_id: input.eventoId, - giocatore_id: input.giocatoreId, - giocatore_nome: input.nome, - aggiornato_il: new Date().toISOString(), - }, - { onConflict: "evento_id" }, + await upsertByFilter( + "scout_sessioni", + "evento = {:evento}", + { evento: input.eventoId }, + { evento: input.eventoId, giocatore: input.giocatoreId, giocatore_nome: input.nome }, ); - if (error) throw error; return true; }, onSuccess: (_ok, input) => { @@ -92,13 +112,17 @@ export function useChiudiSessioneScout() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { eventoId: string; giocatoreId: string }) => { - const attuale = await leggiSessione(input.eventoId); - if (attuale && attuale.giocatore_id === input.giocatoreId) { - const { error } = await supabase - .from("scout_sessioni") - .delete() - .eq("evento_id", input.eventoId); - if (error) throw error; + const attuale = await pb + .collection("scout_sessioni") + .getFirstListItem( + pb.filter("evento = {:evento}", { evento: input.eventoId }), + ) + .catch((errore) => { + if (errore instanceof ClientResponseError && errore.status === 404) return null; + throw errore; + }); + if (attuale && attuale.giocatore === input.giocatoreId) { + await pb.collection("scout_sessioni").delete(attuale.id); } }, onSuccess: (_d, input) => { @@ -116,11 +140,21 @@ export function useHeartbeatScout( useEffect(() => { if (!attivo || !eventoId || !giocatoreId) return; const id = window.setInterval(() => { - void supabase - .from("scout_sessioni") - .update({ aggiornato_il: new Date().toISOString() }) - .eq("evento_id", eventoId) - .eq("giocatore_id", giocatoreId); + // Solo aggiornamento, mai creazione: se la sessione è stata chiusa o presa da un + // altro giocatore nel frattempo, il filtro non trova nulla e il battito è un no-op + // (stesso comportamento dell'update Supabase originale). Riscrivere `giocatore` con + // lo stesso valore basta a PocketBase per aggiornare `updated`, che qui fa le veci + // di `aggiornato_il`. + void pb + .collection("scout_sessioni") + .getFirstListItem( + pb.filter("evento = {:evento} && giocatore = {:giocatore}", { + evento: eventoId, + giocatore: giocatoreId, + }), + ) + .then((riga) => pb.collection("scout_sessioni").update(riga.id, { giocatore: giocatoreId })) + .catch(() => {}); }, 60_000); return () => window.clearInterval(id); }, [attivo, eventoId, giocatoreId]); diff --git a/src/lib/scout-stato.ts b/src/lib/scout-stato.ts index 10d568e..d72b0a9 100644 --- a/src/lib/scout-stato.ts +++ b/src/lib/scout-stato.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { ClientResponseError } from "pocketbase"; +import { pb } from "@/integrations/pocketbase/client"; +import { upsertByFilter } from "@/integrations/pocketbase/upsert"; import type { Azione } from "./scout-store"; /** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */ @@ -19,6 +21,8 @@ export const statoIniziale = (avversario: string, casa: boolean): StatoScout => export const SCOUT_STATO_KEY = (eventoId: string) => ["scout-stato", eventoId] as const; +type RigaScoutLive = { evento: string; stato: unknown }; + export function useStatoScout(eventoId: string | null) { return useQuery({ queryKey: SCOUT_STATO_KEY(eventoId ?? "-"), @@ -26,13 +30,14 @@ export function useStatoScout(eventoId: string | null) { staleTime: Infinity, queryFn: async (): Promise => { if (!eventoId) return null; - const { data, error } = await supabase - .from("scout_live") - .select("stato") - .eq("evento_id", eventoId) - .maybeSingle(); - if (error) throw error; - const stato = data?.stato as StatoScout | undefined; + const riga = await pb + .collection("scout_live") + .getFirstListItem(pb.filter("evento = {:evento}", { evento: eventoId })) + .catch((errore) => { + if (errore instanceof ClientResponseError && errore.status === 404) return null; + throw errore; + }); + const stato = riga?.stato as StatoScout | undefined; if (!stato || !Array.isArray(stato.azioni)) return null; return stato; }, @@ -43,15 +48,12 @@ export function useSalvaStatoScout() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { eventoId: string; stato: StatoScout }) => { - const { error } = await supabase.from("scout_live").upsert( - { - evento_id: input.eventoId, - stato: JSON.parse(JSON.stringify(input.stato)), - aggiornato_il: new Date().toISOString(), - }, - { onConflict: "evento_id" }, + await upsertByFilter( + "scout_live", + "evento = {:evento}", + { evento: input.eventoId }, + { evento: input.eventoId, stato: JSON.parse(JSON.stringify(input.stato)) }, ); - if (error) throw error; return input; }, onSuccess: (input) => { @@ -64,8 +66,16 @@ export function useCancellaStatoScout() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (eventoId: string) => { - const { error } = await supabase.from("scout_live").delete().eq("evento_id", eventoId); - if (error) throw error; + const riga = await pb + .collection("scout_live") + .getFirstListItem( + pb.filter("evento = {:evento}", { evento: eventoId }), + ) + .catch((errore) => { + if (errore instanceof ClientResponseError && errore.status === 404) return null; + throw errore; + }); + if (riga) await pb.collection("scout_live").delete(riga.id); return eventoId; }, onSuccess: (eventoId) => { diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index b89638f..bc1f648 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { pb } from "@/integrations/pocketbase/client"; import { giocatori, type Giocatore } from "./crapp-data"; export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv"; @@ -85,7 +85,7 @@ type RigaScoutPartita = { function daRiga(r: RigaScoutPartita): ScoutMatch { return { id: r.id, - data: r.data, + data: r.data.slice(0, 10), avversario: r.avversario, casa: r.casa, setNostri: r.set_nostri, @@ -98,12 +98,10 @@ function daRiga(r: RigaScoutPartita): ScoutMatch { export const SCOUT_MATCHES_KEY = ["scout-partite"] as const; async function fetchScoutMatches(): Promise { - const { data, error } = await supabase - .from("scout_partite") - .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni") - .order("creato_il", { ascending: false }); - if (error) throw error; - return (data ?? []).map(daRiga); + const righe = await pb.collection("scout_partite").getFullList({ + sort: "-created", + }); + return righe.map(daRiga); } /** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi @@ -121,9 +119,9 @@ export function useSalvaScoutMatch() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => { - const { error } = await supabase.from("scout_partite").insert({ + await pb.collection("scout_partite").create({ id: input.match.id, - evento_id: input.eventoId, + evento: input.eventoId ?? "", data: input.match.data, avversario: input.match.avversario, casa: input.match.casa, @@ -132,10 +130,9 @@ export function useSalvaScoutMatch() { parziali: JSON.parse(JSON.stringify(input.match.parziali)), azioni: JSON.parse(JSON.stringify(input.match.azioni)), }); - if (error) throw error; return input.match; }, - // La query ordina per `creato_il` decrescente: la partita appena salvata è la più recente. + // La query ordina per creazione decrescente: la partita appena salvata è la più recente. onSuccess: (match) => queryClient.setQueryData(SCOUT_MATCHES_KEY, (prec) => [ match, @@ -148,8 +145,7 @@ export function useEliminaScoutMatch() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { - const { error } = await supabase.from("scout_partite").delete().eq("id", id); - if (error) throw error; + await pb.collection("scout_partite").delete(id); return id; }, onSuccess: (id) => diff --git a/test/unit/eventi.test.ts b/test/unit/eventi.test.ts index 46b12b2..b72b08a 100644 --- a/test/unit/eventi.test.ts +++ b/test/unit/eventi.test.ts @@ -23,10 +23,10 @@ const riga: RigaEvento = { campionato: true, casa: true, pagelle_chiuse: false, - creato_il: "2026-08-20T09:00:00Z", + created: "2026-08-20 09:00:00.000Z", }; -// --- daRiga: i NULL del database diventano valori sicuri --------------------- +// --- daRiga: normalizza data/creato_il, PocketBase non ha NULL (solo "", [], false) ----- assert.deepEqual(daRiga(riga), { id: "e1", tipo: "partita", @@ -39,20 +39,20 @@ assert.deepEqual(daRiga(riga), { campionato: true, casa: true, pagelleChiuse: false, - creatoIl: "2026-08-20T09:00:00Z", + creatoIl: "2026-08-20T09:00:00.000Z", }); const vuota = daRiga({ ...riga, - note: null, - convocati: null, - casa: null, + note: "", + convocati: [], + casa: false, campionato: false, pagelle_chiuse: false, }); -assert.equal(vuota.note, "", "note NULL → stringa vuota"); -assert.deepEqual(vuota.convocati, [], "convocati NULL → tutta la rosa (array vuoto)"); -assert.equal(vuota.casa, true, "casa NULL → si gioca in casa"); +assert.equal(vuota.note, "", "note vuota resta vuota"); +assert.deepEqual(vuota.convocati, [], "convocati vuoti → tutta la rosa (array vuoto)"); +assert.equal(vuota.casa, false, "casa è quello che c'è in database, nessun default implicito"); assert.equal(vuota.pagelleChiuse, false); // --- categoriaEvento: l'amichevole è una partita fuori campionato ------------ @@ -100,11 +100,7 @@ assert.deepEqual( ["g2"], "con i convocati indicati si filtra", ); -assert.deepEqual( - convocatiEvento(daRiga({ ...riga, convocati: null }), rosa), - rosa, - "vuoto = tutti", -); +assert.deepEqual(convocatiEvento(daRiga({ ...riga, convocati: [] }), rosa), rosa, "vuoto = tutti"); assert.deepEqual(convocatiEvento(null, rosa), rosa, "senza evento restano tutti"); assert.deepEqual( convocatiEvento(daRiga({ ...riga, convocati: ["ignoto"] }), rosa),