diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 61b7d79..741c9ad 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -46,7 +46,7 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). `npx supabase migration list`) - Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente` per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca - schema o comportamento esistenti, vedi DD-030). Il codice che la usa (icona notifiche, + schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche, route aggiornate) non è ancora deployato: la tabella si riempie già in background - Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna `giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 930f283..6a50f69 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,12 @@ all'indietro. ## [Non rilasciato] +### Modificato + +- **Notifiche in-app** — tolta la campanella in alto a destra: il numero delle notifiche è ora + un pallino rosso sull'avatar del profilo. Toccando il pallino si apre l'elenco (leggi ed + elimina), toccando l'avatar si va al profilo come prima. + ## [0.9.2] - 2026-09-10 ### Aggiunto diff --git a/docs/DATABASE.md b/docs/DATABASE.md index dba9d64..1564373 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -75,7 +75,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa | `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | | `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | | `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | -| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, accanto al profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin. Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. | +| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin. Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. | ## Funzioni speciali diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 91dd055..912cad8 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -1184,8 +1184,10 @@ RLS (`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()` in `useSalvaEvento()` in `eventi.ts`. Nessuna policy INSERT per `authenticated`: le righe nascono solo da funzioni `SECURITY DEFINER` o dalla service role, mai da un client. -Icona a campana con badge (`IconaNotifiche`, `src/components/crapp/ui-bits.tsx`), pannello a -comparsa costruito a mano (nel progetto non c'è una libreria dropdown). Ogni riga si elimina +Pallino con il conteggio sull'avatar del profilo (`PallinoNotifiche`, +`src/components/crapp/ui-bits.tsx`; in origine una campanella separata, tolta per liberare +l'header: il tap sull'avatar resta l'accesso al profilo, quello sul pallino apre il +pannello), pannello a comparsa costruito a mano (nel progetto non c'è una libreria dropdown). Ogni riga si elimina con uno swipe o una ×: non c'è pulizia automatica delle notifiche vecchie, quindi è l'unico modo per un giocatore di toglierle di torno per sempre. @@ -1211,7 +1213,7 @@ modo per un giocatore di toglierle di torno per sempre. **Conseguenze** -- Il badge sull'icona riflette solo ciò che sta in `notifiche_utente`: un dispositivo senza +- Il pallino sull'avatar riflette solo ciò che sta in `notifiche_utente`: un dispositivo senza push attive vede comunque tutto, perché il canale è del tutto indipendente. - `eventi_app.ora` è testo libero e non validato dal form (`` si può svuotare): la funzione `genera_promemoria_eventi()` usa `ora_evento_a_time()`, che assorbe diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c87f57..bb70edf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,8 +21,8 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md` - [x] Turno palloni - [x] Infortuni — conteggio eventi saltati, in forma minima - [x] Notifiche Push (promemoria intelligenti) -- [x] Centro notifiche in-app — icona a campana accanto al profilo, con badge delle non - lette, indipendente dalla push (messaggi admin, promemoria evento, turno palloni, +- [x] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del + profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni, sollecito presenze) - [x] Dashboard amministratore - [x] Download CSV dati diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index b9329bb..d404825 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -1,11 +1,11 @@ # Modulo — Notifiche **Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un -centro notifiche in-app indipendente accanto al profilo +centro notifiche in-app indipendente, con un pallino sull'avatar del profilo **File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`, `src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`, `src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`, -`src/components/crapp/ui-bits.tsx` (`IconaNotifiche`) +`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`) --- @@ -83,10 +83,10 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna variabile d'ambiente. -| Route | Controllo | Chi la chiama | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Route | Controllo | Chi la chiama | +| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin | -| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | +| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | `notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche" @@ -116,8 +116,12 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata. ## Centro notifiche in-app (M17) -Icona a campana accanto al link profilo (`IconaNotifiche` in `src/components/crapp/ui-bits.tsx`, -dentro `PageHeader`), con badge del numero di non lette. Indipendente dal canale push sopra: +Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro +`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se +sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per +eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il +tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una +campanella separata accanto all'avatar. Indipendente dal canale push sopra: non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in `notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la stessa cosa delle notifiche smart (quelle restano locali, non salvate a database). diff --git a/src/components/crapp/ui-bits.tsx b/src/components/crapp/ui-bits.tsx index f0b371d..817af68 100644 --- a/src/components/crapp/ui-bits.tsx +++ b/src/components/crapp/ui-bits.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { Link } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; -import { Bell, ChevronDown, X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data"; import { nomeCompleto } from "@/lib/giocatori-squadra"; @@ -16,7 +16,7 @@ import { useGiocatoreBase } from "@/lib/user-store"; import { useMotoRidotto } from "@/lib/motion"; import { molla, proietta } from "@/lib/molla"; import { - contatoreBadge, + pallinoNotifiche, useEliminaNotifica, useNotificheMie, useSegnaLette, @@ -65,30 +65,6 @@ export function Card({ return ; } -/** - * Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è - * l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a - * `PageHeader` con il logo che porta alla home. - * - * Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e - * iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle, - * cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni - * pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte. - */ -export function LinkProfilo() { - const g = useGiocatoreBase(); - if (!g) return ; - return ( - - - - ); -} - /** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */ function tempoRelativo(dataIso: string): string { const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" }); @@ -150,8 +126,38 @@ function RigaNotifica({ } /** - * Campanella accanto al profilo (M17): badge con le notifiche non lette, pannello con - * l'elenco al click. Copre quattro sorgenti generate lato database (mai dal client): + * Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è + * l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a + * `PageHeader` con il logo che porta alla home. + * + * Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e + * iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle, + * cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni + * pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte. + * + * Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar + * porta sempre al profilo, quello sul pallino apre il pannello. + */ +export function LinkProfilo() { + const g = useGiocatoreBase(); + if (!g) return ; + return ( +
+ + + + +
+ ); +} + +/** + * Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da + * `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client): * messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi * `src/lib/notifiche-utente.ts`. * @@ -159,7 +165,7 @@ function RigaNotifica({ * chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per * toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`. */ -export function IconaNotifiche() { +function PallinoNotifiche() { const { notifiche, nonLette } = useNotificheMie(); const segnaLette = useSegnaLette(); const eliminaNotifica = useEliminaNotifica(); @@ -182,40 +188,48 @@ export function IconaNotifiche() { }; }, [aperto]); + const pallino = pallinoNotifiche(notifiche.length, nonLette); + if (!pallino) return null; + function alClick() { const stavaChiuso = !aperto; setAperto(stavaChiuso); if (stavaChiuso && nonLette > 0) segnaLette.mutate(); } + // Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima + // notifica non si riapre da solo. + function elimina(id: string) { + if (notifiche.length === 1) setAperto(false); + eliminaNotifica.mutate(id); + } + return ( -
+
+ {/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */} {aperto ? (
- {notifiche.length === 0 ? ( -

Nessuna notifica

- ) : ( -
    - - {notifiche.map((n) => ( - - ))} - -
- )} +
    + + {notifiche.map((n) => ( + + ))} + +
) : null}
@@ -241,12 +255,7 @@ export function PageHeader({

{sottotitolo}

) : null}
- {azione ?? ( -
- - -
- )} + {azione ?? } ); diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts index 8cbca40..5397520 100644 --- a/src/lib/notifiche-utente.ts +++ b/src/lib/notifiche-utente.ts @@ -3,7 +3,7 @@ import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabel import { useGiocatoreId } from "./user-store"; /** - * Centro notifiche in-app (M17): icona a campana accanto al profilo, con badge delle non + * Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non * lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un * messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il * turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in @@ -129,3 +129,17 @@ export function useEliminaNotifica() { export function contatoreBadge(nonLette: number): string { return nonLette > 9 ? "9+" : String(nonLette); } + +/** + * Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette + * resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più + * raggiungibili per eliminarle. Senza notifiche non c'è (`null`). + */ +export function pallinoNotifiche( + totale: number, + nonLette: number, +): { testo: string; daLeggere: boolean } | null { + if (totale === 0) return null; + if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true }; + return { testo: contatoreBadge(totale), daLeggere: false }; +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 41f55b6..6b60011 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -2,14 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { Flame, ChevronRight } from "lucide-react"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni"; -import { - Card, - IconaNotifiche, - LinkProfilo, - Section, - StatTile, - TeamLogo, -} from "@/components/crapp/ui-bits"; +import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { Reveal } from "@/components/motion/Reveal"; import { Barra } from "@/components/motion/Barra"; @@ -90,10 +83,7 @@ function Index() {

CRAP Volley

-
- - -
+
diff --git a/test/unit/notifiche-utente.test.ts b/test/unit/notifiche-utente.test.ts index 7e902c3..256abfc 100644 --- a/test/unit/notifiche-utente.test.ts +++ b/test/unit/notifiche-utente.test.ts @@ -1,6 +1,11 @@ /** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */ import assert from "node:assert/strict"; -import { contatoreBadge, daRiga, type RigaNotifica } from "@/lib/notifiche-utente"; +import { + contatoreBadge, + daRiga, + pallinoNotifiche, + type RigaNotifica, +} from "@/lib/notifiche-utente"; // --- daRiga: conversione database -> modello applicativo --------------------- const riga: RigaNotifica = { @@ -36,4 +41,11 @@ assert.equal(contatoreBadge(9), "9"); assert.equal(contatoreBadge(10), "9+"); assert.equal(contatoreBadge(42), "9+"); +// --- pallinoNotifiche: rosso con le non lette, neutro col totale, assente se vuoto --- +assert.equal(pallinoNotifiche(0, 0), null); +assert.deepEqual(pallinoNotifiche(5, 2), { testo: "2", daLeggere: true }); +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 }); + console.log("notifiche-utente: ok");