diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 9115080..958d218 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -15,6 +15,9 @@ versioni `0.y.z` erano pre-release.
### Aggiunto
+- **Obiettivi cliccabili** — toccando un obiettivo (in Squadra o nella card della Home) si apre una card che
+ spiega tutto: come si calcola, cosa conta e cosa no, periodo di validità, fonte dei dati, un esempio. Nessuna
+ migration.
- **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri
attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo
messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration
@@ -22,6 +25,8 @@ versioni `0.y.z` erano pre-release.
### Modificato
+- **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se
+ non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Nessuna migration.
- **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene
creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration.
- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la
diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md
index 4306ac3..a84df8c 100644
--- a/docs/modules/obiettivi-squadra.md
+++ b/docs/modules/obiettivi-squadra.md
@@ -39,7 +39,7 @@ corrente dinamico (vedi sotto).
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
-| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
+| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
@@ -51,7 +51,12 @@ corrente dinamico (vedi sotto).
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
-primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
+primo obiettivo non completato).
+Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
+badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i
+dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in
+`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo
+`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
@@ -75,8 +80,9 @@ passaggio a completato compare al refresh successivo.
`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli
allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono
-conferme). Differisce da `o1` per periodo (stagione, non mese) e perché i posti sono i convocati
-di ciascun evento, non sempre l'intera rosa. Non c'è un confine di stagione esplicito: conta ciò
+conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i
+convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione
+`percentualePresenze()`. Non c'è un confine di stagione esplicito: conta ciò
che c'è in `eventi_app`.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
@@ -156,14 +162,22 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
-| Obiettivi | Unit test | Integration test |
-| ---------- | ----------------------------- | -------------------------------------------------------- |
-| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
-| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
-| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
-| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
-| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
+| Obiettivi | Unit test | Integration test |
+| ---------- | ----------------------------- | -------------------------------------------------------------------------------- |
+| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) |
+| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
+| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
+| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
+| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
+- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti
+ contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa,
+ confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia
+ del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test
+ diventa rosso.
+- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi,
+ «Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato
+ in corso/completato.
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
diff --git a/src/components/crapp/ObiettivoDrawer.tsx b/src/components/crapp/ObiettivoDrawer.tsx
new file mode 100644
index 0000000..2662f1b
--- /dev/null
+++ b/src/components/crapp/ObiettivoDrawer.tsx
@@ -0,0 +1,142 @@
+import { useState, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import { formatData } from "@/lib/crapp-data";
+import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi";
+import { Barra } from "@/components/motion/Barra";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+
+function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) {
+ return (
+
+ );
+}
+
+/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */
+export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
+ const pct = progressoObiettivo(o);
+ const fatto = pct >= 100;
+ const d = o.dettaglio;
+ return (
+
+ );
+}
+
+/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */
+export function ObiettivoDrawer({
+ obiettivo,
+ children,
+}: {
+ obiettivo: ObiettivoSquadra;
+ children: ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+ {children}
+
+
+ {obiettivo.titolo}
+ {obiettivo.descrizione}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts
index 286b5d7..7c9064f 100644
--- a/src/lib/obiettivi.ts
+++ b/src/lib/obiettivi.ts
@@ -14,6 +14,25 @@ export type ObiettivoSquadra = {
emoji: string;
/** Frase breve che spiega come ogni giocatore può spostare l'ago. */
impatto: string;
+ /** Spiegazione completa mostrata nella card di dettaglio: nessun punto oscuro. */
+ dettaglio: DettaglioObiettivo;
+};
+
+export type DettaglioObiettivo = {
+ /** Cosa misura l'obiettivo e come si arriva al numero mostrato, in parole semplici. */
+ comeSiCalcola: string;
+ /** Cosa fa salire il numero. */
+ conta: string[];
+ /** Cosa è una presenza persa: occupa un posto ma lo lascia vuoto, quindi abbassa la percentuale. */
+ perse?: string[];
+ /** Cosa resta fuori dal calcolo: non sale e non scende. */
+ nonConta: string[];
+ /** Quando l'obiettivo parte, quando si azzera o scade. */
+ periodo: string;
+ /** Da dove arrivano i dati e quando si aggiornano. */
+ fonte: string;
+ /** Esempio concreto con numeri. */
+ esempio: string;
};
export type ContestoObiettivi = {
@@ -67,28 +86,21 @@ function fineMese(oggi: Date): string {
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
}
-function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
- const delMese = ctx.eventi.filter(
- (e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
- );
- if (delMese.length === 0 || rosaSize === 0) return 0;
- const posti = delMese.length * rosaSize;
- const presenti = delMese.reduce((s, e) => {
- const risposte = ctx.presenze[e.id] ?? {};
- return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
- }, 0);
- return Math.round((presenti / posti) * 100);
-}
-
/**
- * Presenze (presente o ritardo) su tutti gli allenamenti e le partite definiti, passati e
- * futuri, sul totale dei posti: per ogni evento i convocati, o tutta la rosa se non ce ne sono.
+ * Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da
+ * `filtro`: per ogni evento i posti sono i convocati, o tutta la rosa se non ce ne sono. Chi non
+ * è convocato non occupa un posto, quindi la sua assenza non pesa.
*/
-function percentualePresenzeCollettive(ctx: ContestoObiettivi, rosa: Giocatore[]) {
+function percentualePresenze(
+ ctx: ContestoObiettivi,
+ rosa: Giocatore[],
+ filtro: (e: Evento) => boolean = () => true,
+) {
let posti = 0;
let presenti = 0;
for (const e of ctx.eventi) {
if (e.tipo !== "partita" && e.tipo !== "allenamento") continue;
+ if (!filtro(e)) continue;
const attesi = e.convocati.length === 0 ? rosa : rosa.filter((g) => e.convocati.includes(g.id));
posti += attesi.length;
const risposte = ctx.presenze[e.id] ?? {};
@@ -126,12 +138,35 @@ export function obiettiviSquadra(
id: "o1",
titolo: `90% di presenze ${aMese(oggi)}`,
descrizione: "Media presenze su partite e allenamenti del mese",
- valore: percentualePresenzeMese(ctx, rosa.length, mese),
+ valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)),
target: 90,
unita: "%",
scadenza: fineMese(oggi),
emoji: "📣",
impatto: "Ogni sì in più alza la media di tutta la squadra.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si guardano solo le partite e gli allenamenti del mese in corso. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi del mese.",
+ conta: [
+ "«Presente» o «in ritardo» di un convocato a una partita o a un allenamento del mese.",
+ "Vale anche per gli eventi futuri del mese: appena confermi, la percentuale sale.",
+ ],
+ perse: [
+ "«Assente» di un convocato: è una presenza persa, il posto resta vuoto.",
+ "«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.",
+ ],
+ nonConta: [
+ "Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.",
+ "Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.",
+ "Gli eventi degli altri mesi.",
+ ],
+ periodo:
+ "Solo il mese in corso: si azzera il primo giorno di ogni mese e si chiude l'ultimo giorno del mese. Il mese dopo riparte da zero, con il suo titolo e la sua scadenza.",
+ fonte:
+ "Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.",
+ esempio:
+ "Ad agosto ci sono 2 allenamenti. Il primo ha 12 convocati e 10 rispondono «presente»: 10 posti pieni su 12. Il secondo ha 10 convocati e 9 «presenti»: 9 su 10. In tutto 19 posti pieni su 22, cioè 86%. I giocatori non convocati non sono nel conto: per arrivare al 90% servono almeno 20 posti pieni su 22.",
+ },
},
{
id: "o2",
@@ -143,16 +178,55 @@ export function obiettiviSquadra(
scadenza: fineMese(oggi),
emoji: "⚡",
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
+ dettaglio: {
+ comeSiCalcola:
+ "Per ogni evento in calendario ci sono tanti posti quanti sono i giocatori in rosa. Si contano le risposte date, di qualsiasi tipo, e si divide per tutti i posti.",
+ conta: [
+ "Qualsiasi risposta: presente, in ritardo o assente. Conta aver risposto, non cosa si è risposto.",
+ "Partite, allenamenti ed eventi di squadra.",
+ ],
+ nonConta: [
+ "I compleanni: non richiedono risposta.",
+ "Un evento a cui non hai ancora risposto.",
+ ],
+ periodo:
+ "Considera tutti gli eventi in programma, non solo quelli del mese. La scadenza indicata è la fine del mese corrente.",
+ fonte: "Eventi del calendario e risposte dei giocatori. Si aggiorna a ogni risposta.",
+ esempio:
+ "Con 14 giocatori e 10 eventi i posti sono 140. Con 126 risposte date, siamo al 90%.",
+ },
},
{
id: "o7",
titolo: "Presenze collettive",
descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
- valore: percentualePresenzeCollettive(ctx, rosa),
+ valore: percentualePresenze(ctx, rosa),
target: 90,
unita: "%",
emoji: "🤝",
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si guardano tutte le partite e tutti gli allenamenti della stagione, passati e futuri. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi insieme.",
+ conta: [
+ "«Presente» o «in ritardo» di un convocato a una partita o a un allenamento.",
+ "Anche le conferme per gli eventi futuri: appena confermi, la percentuale sale.",
+ ],
+ perse: [
+ "«Assente» di un convocato: è una presenza persa, il posto resta vuoto.",
+ "«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.",
+ ],
+ nonConta: [
+ "Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.",
+ "Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.",
+ ],
+ periodo:
+ "Tutta la stagione: non si azzera mai e non ha scadenza. Conta tutto ciò che c'è in calendario, quindi un evento cancellato smette di contare e uno nuovo aggiunge i suoi posti.",
+ fonte:
+ "Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.",
+ esempio:
+ "In stagione ci sono 3 eventi: un allenamento con 12 convocati e 10 presenti, uno con 10 convocati e 9 presenti, una partita con 14 convocati e 13 presenti. Posti pieni 32 su 36, cioè 89%: per il 90% manca una presenza in più. La differenza con quello del mese è solo il periodo: qui contano tutti gli eventi insieme, senza azzerarsi.",
+ },
},
{
id: "o12",
@@ -163,6 +237,19 @@ export function obiettiviSquadra(
unita: "di media",
emoji: "📝",
impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.",
+ dettaglio: {
+ comeSiCalcola:
+ "Media di tutti i voti dati dai giocatori nelle pagelle dopo le partite, arrotondata a una cifra decimale.",
+ conta: ["Ogni voto dato in una pagella, a qualsiasi compagno."],
+ nonConta: [
+ "Gli autovoti: non si può votare se stessi.",
+ "Le pagelle non compilate: non pesano sulla media.",
+ ],
+ periodo: "Tutta la stagione. Non si azzera e non ha scadenza.",
+ fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.",
+ esempio:
+ "Se i voti dati finora sono 7, 8 e 7, la media è 7.3: mancano 0.2 per arrivare a 7.5.",
+ },
},
{
id: "o13",
@@ -173,6 +260,16 @@ export function obiettiviSquadra(
unita: "voti",
emoji: "🗳️",
impatto: "Vota i compagni a fine partita: bastano due minuti.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si contano tutti i voti dati nelle pagelle durante la stagione. Un voto è un giocatore che valuta un compagno dopo una partita.",
+ conta: ["Ogni singolo voto dato a un compagno, non ogni pagella intera."],
+ nonConta: ["Le pagelle non compilate.", "Gli autovoti, che non sono ammessi."],
+ periodo: "Tutta la stagione. Non si azzera e non ha scadenza.",
+ fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.",
+ esempio:
+ "Se dopo una partita 10 giocatori votano 9 compagni ciascuno, la squadra aggiunge 90 voti.",
+ },
},
{
id: "o11",
@@ -183,6 +280,21 @@ export function obiettiviSquadra(
unita: "giocatori",
emoji: "🔗",
impatto: "Tieni viva la tua serie e sblocchi anche questo.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si conta quanti giocatori hanno una serie di almeno 3 allenamenti consecutivi senza saltarne nemmeno uno.",
+ conta: ["Un giocatore con 3 o più allenamenti di fila in cui era presente o in ritardo."],
+ nonConta: [
+ "Chi ha saltato un allenamento: la serie riparte da zero.",
+ "Un allenamento passato senza risposta vale come assenza e azzera la serie.",
+ "Le partite: la serie riguarda solo gli allenamenti.",
+ "Un allenamento saltato per infortunio non la azzera: la serie resta ferma al valore di prima.",
+ ],
+ periodo: "Si aggiorna di continuo sugli allenamenti passati. Non ha scadenza.",
+ fonte: "Presenze agli allenamenti. Vedi anche la serie personale nel tuo profilo.",
+ esempio:
+ "Il target è 12 perché è il numero minimo per un allenamento 6 contro 6. Con 9 giocatori in serie ne mancano 3.",
+ },
},
{
id: "o3",
@@ -193,6 +305,22 @@ export function obiettiviSquadra(
unita: "vittorie",
emoji: "🎉",
impatto: "Una prestazione di gruppo e il primo passo è fatto.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con la prima vittoria.",
+ conta: [
+ "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
+ ],
+ nonConta: [
+ "Le sconfitte e le partite non ancora giocate.",
+ "Amichevoli e allenamenti.",
+ "Le vittorie oltre il target: il contatore si ferma al traguardo.",
+ ],
+ periodo: "Tutta la stagione di campionato. Non si azzera.",
+ fonte:
+ "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
+ esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
+ },
},
{
id: "o4",
@@ -203,6 +331,22 @@ export function obiettiviSquadra(
unita: "vittorie",
emoji: "🔥",
impatto: "Ogni vittoria ci avvicina ai playoff.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 5 vittorie.",
+ conta: [
+ "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
+ ],
+ nonConta: [
+ "Le sconfitte e le partite non ancora giocate.",
+ "Amichevoli e allenamenti.",
+ "Le vittorie oltre il target: il contatore si ferma al traguardo.",
+ ],
+ periodo: "Tutta la stagione di campionato. Non si azzera.",
+ fonte:
+ "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
+ esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
+ },
},
{
id: "o5",
@@ -213,6 +357,22 @@ export function obiettiviSquadra(
unita: "vittorie",
emoji: "🏆",
impatto: "L'obiettivo grande: serve tutta la stagione insieme.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 10 vittorie.",
+ conta: [
+ "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
+ ],
+ nonConta: [
+ "Le sconfitte e le partite non ancora giocate.",
+ "Amichevoli e allenamenti.",
+ "Le vittorie oltre il target: il contatore si ferma al traguardo.",
+ ],
+ periodo: "Tutta la stagione di campionato. Non si azzera.",
+ fonte:
+ "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
+ esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
+ },
},
{
id: "o6",
@@ -229,6 +389,24 @@ export function obiettiviSquadra(
unita: "eventi",
emoji: "🍕",
impatto: "Il gruppo si costruisce anche fuori dal campo.",
+ dettaglio: {
+ comeSiCalcola:
+ "Si conta quanti eventi di squadra del mese corrente sono già iniziati. Basta uno per completare l'obiettivo.",
+ conta: [
+ "Pizzate, cene e uscite fuori dal campo inserite nel calendario come «evento».",
+ "Solo quando l'ora dell'evento è arrivata. Se manca l'ora vale la mezzanotte.",
+ ],
+ nonConta: [
+ "Un evento ancora nel futuro: creato oggi per la settimana prossima non conta ancora.",
+ "Partite e allenamenti.",
+ "Eventi di altri mesi.",
+ ],
+ periodo:
+ "Si azzera il primo giorno di ogni mese. Il passaggio a completato compare al primo aggiornamento della pagina dopo l'ora dell'evento.",
+ fonte: "Eventi del calendario.",
+ esempio:
+ "Una pizzata fissata il 20 alle 20:30: fino a quell'ora l'obiettivo resta 0/1, dopo diventa 1/1.",
+ },
},
];
}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 2c72149..6506de1 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -9,6 +9,7 @@ import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
+import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { useRispostePresenze } from "@/lib/presenze";
@@ -237,20 +238,32 @@ function Index() {
}
>
{obiettivo ? (
-
-
- {obiettivo.emoji} {obiettivo.titolo}
-
-
-
- Siamo al —{" "}
- {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
-
-
- {microcopyObiettivo(obiettivo)}
-
-
{obiettivo.impatto}
-
+
+
+
) : null}
)}
diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx
index 13591a6..f4deba7 100644
--- a/src/routes/squadra.tsx
+++ b/src/routes/squadra.tsx
@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
-import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
+import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData, inizialiDa } from "@/lib/crapp-data";
+import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import {
useRosa,
@@ -432,38 +433,47 @@ function Squadra() {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
- = 90 ? "ring-accent/40" : "ring-transparent",
- )}
- >
-